From cc9fdaa7f00d2cae5ea92ff7bce47bb0713a1806 Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 14:26:37 +0200 Subject: [PATCH 01/44] autotester: Docker-based in-game integration test framework Adds a docker-based autotest framework that runs real Minecraft server + client containers, drives the UI through a file-based JSON bridge (AutoTestBridge), and validates the modpack sync flow against 22 version/loader targets. Includes mod-side changes required to expose UI state for testing: AutoTestBridge, async certificate trust helpers, EditBox inputText persistence, and infrastructure cleanup for unsupported MC versions. --- .github/workflows/ingame-tests.yml | 147 ++++ .gitignore | 12 +- autotester/README.md | 419 +++++++++++ autotester/automodpack_autotester/__init__.py | 5 + autotester/automodpack_autotester/bridge.py | 34 + autotester/automodpack_autotester/cli.py | 182 +++++ autotester/automodpack_autotester/config.py | 63 ++ autotester/automodpack_autotester/docker.py | 114 +++ autotester/automodpack_autotester/runner.py | 665 ++++++++++++++++++ autotester/docker/client/Dockerfile | 41 ++ .../docker/client/run-headlessmc-client | 64 ++ autotester/pyproject.toml | 19 + autotester/scenarios/download-only.yaml | 49 ++ autotester/scenarios/sync.yaml | 121 ++++ autotester/settings.yaml | 80 +++ autotester/targets.yaml | 27 + autotester/uv.lock | 69 ++ build.fabric.gradle.kts | 15 +- build.forge.gradle.kts | 1 - build.neoforge.gradle.kts | 19 +- buildSrc/src/main/kotlin/MergeJarTask.kt | 2 +- buildSrc/src/main/kotlin/ModuleUtils.kt | 6 +- .../main/kotlin/automodpack.common.gradle.kts | 11 +- .../protocol/DownloadClient.java | 170 +++-- .../automodpack_loader_core/Preload.java | 42 +- .../client/ModpackUpdater.java | 21 +- .../client/ModpackUtils.java | 123 +++- loader/loader-fabric-core.gradle.kts | 1 + loader/loader-forge.gradle.kts | 1 + loader/loader-neoforge.gradle.kts | 22 +- .../EarlyModLocator.java | 27 +- .../LazyModLocator.java | 41 +- .../resources/META-INF/jarjar/metadata.json | 12 + ...ed.neoforgespi.locating.IDependencyLocator | 1 - settings.gradle.kts | 3 - .../skidam/automodpack/client/ScreenImpl.java | 46 +- .../client/autotest/AutoTestBridge.java | 255 +++++++ .../automodpack/client/ui/DangerScreen.java | 11 +- .../ui/FingerprintVerificationScreen.java | 32 +- .../skidam/automodpack/init/FabricInit.java | 4 +- .../pl/skidam/automodpack/init/ForgeInit.java | 4 +- .../skidam/automodpack/init/NeoForgeInit.java | 2 + .../mixin/core/FabricLoginMixin.java | 7 +- .../core/LoginQueryRequestS2CPacketMixin.java | 18 +- .../LoginQueryResponseC2SPacketMixin.java | 19 +- .../networking/packet/DataC2SPacket.java | 199 +++--- stonecutter.properties.toml | 36 - 47 files changed, 2945 insertions(+), 317 deletions(-) create mode 100644 .github/workflows/ingame-tests.yml create mode 100644 autotester/README.md create mode 100644 autotester/automodpack_autotester/__init__.py create mode 100644 autotester/automodpack_autotester/bridge.py create mode 100644 autotester/automodpack_autotester/cli.py create mode 100644 autotester/automodpack_autotester/config.py create mode 100644 autotester/automodpack_autotester/docker.py create mode 100644 autotester/automodpack_autotester/runner.py create mode 100644 autotester/docker/client/Dockerfile create mode 100644 autotester/docker/client/run-headlessmc-client create mode 100644 autotester/pyproject.toml create mode 100644 autotester/scenarios/download-only.yaml create mode 100644 autotester/scenarios/sync.yaml create mode 100644 autotester/settings.yaml create mode 100644 autotester/targets.yaml create mode 100644 autotester/uv.lock create mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json delete mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator create mode 100644 src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java diff --git a/.github/workflows/ingame-tests.yml b/.github/workflows/ingame-tests.yml new file mode 100644 index 000000000..38595ea7f --- /dev/null +++ b/.github/workflows/ingame-tests.yml @@ -0,0 +1,147 @@ +name: In-game Tests + +on: + workflow_dispatch: + inputs: + scenario: + description: "Autotest scenario" + default: "sync" + target: + description: "Target ID (or 'all')" + default: "all" + jobs: + description: "Parallel runs" + default: "1" + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + scenario: ${{ steps.set-scenario.outputs.scenario }} + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - id: set-matrix + run: | + TARGET="${{ github.event.inputs.target || 'all' }}" + ALL=$(uv run --project autotester python -c "import json,yaml; ids=[t['id'] for t in yaml.safe_load(open('autotester/targets.yaml')).get('targets',[])]; print(json.dumps(ids))") + if [ "$TARGET" = "all" ]; then + echo "matrix={\"target\":$ALL}" >> $GITHUB_OUTPUT + else + echo "matrix={\"target\":[\"$TARGET\"]}" >> $GITHUB_OUTPUT + fi + - id: set-scenario + run: | + echo "scenario=${{ github.event.inputs.scenario || 'sync' }}" >> $GITHUB_OUTPUT + + build: + needs: [prepare] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Build AutoModpack + run: ./gradlew build + - uses: actions/upload-artifact@v6 + with: + name: merged-jars + path: merged/ + if-no-files-found: error + + ingame: + needs: [build, prepare] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - uses: actions/download-artifact@v6 + with: + name: merged-jars + path: merged/ + - name: Build autotest client image + run: uv --project autotester run autotester build-images + - name: Run autotest + run: > + uv --project autotester run autotester run + --target "${{ matrix.target }}" + --scenario "${{ needs.prepare.outputs.scenario }}" + - uses: actions/upload-artifact@v6 + if: always() + with: + name: autotester-${{ matrix.target }} + path: autotester/out + + report: + needs: [ingame] + if: always() + runs-on: ubuntu-latest + steps: + - name: Install uv + uses: astral-sh/setup-uv@v7 + - uses: actions/download-artifact@v6 + with: + pattern: autotester-* + merge-multiple: true + - name: Aggregate results + id: aggregate + run: | + cat > /tmp/aggregate.py << 'PYEOF' + import json, glob, os + results = [] + for f in sorted(glob.glob("**/results.json", recursive=True)): + try: + data = json.load(open(f)) + results.extend(data.get("results", [])) + except Exception: + name = f.split(os.sep)[0] if os.sep in f else "unknown" + results.append({"target": name, "ok": False, "error": "results.json missing or invalid"}) + + total = len(results) + passed = sum(1 for r in results if r.get("ok")) + any_ok = passed == total + + for r in results: + if not r.get("ok"): + err = r.get("error", "unknown error") + print(f"::error title={r['target']}::{err}") + + summary = {"ok": any_ok, "total": total, "passed": passed, "failed": total - passed, "results": results} + with open("aggregated.json", "w") as f: + json.dump(summary, f, indent=2) + + md = "## In-game Test Results\n\n" + md += f"**{'PASS' if any_ok else 'FAIL'}** — {passed}/{total} passed, {total - passed} failed\n\n" + md += "| Status | Target | Duration | Error |\n" + md += "|--------|--------|----------|-------|\n" + for r in results: + status = "✅" if r.get("ok") else "❌" + dur = f"{r.get('duration', 0):.1f}s" if "duration" in r else "-" + err = r.get("error", "") if not r.get("ok") else "" + md += f"| {status} | {r['target']} | {dur} | {err} |\n" + + with open("summary.md", "w") as f: + f.write(md) + + print(f"ok={str(any_ok).lower()}", file=open(os.environ["GITHUB_OUTPUT"], "a")) + PYEOF + python3 /tmp/aggregate.py + - uses: actions/upload-artifact@v6 + with: + name: aggregated-results + path: | + aggregated.json + summary.md + - name: Post job summary + run: cat summary.md >> $GITHUB_STEP_SUMMARY + - name: Check overall result + if: steps.aggregate.outputs.ok != 'true' + run: | + echo "Some tests failed" + exit 1 diff --git a/.gitignore b/.gitignore index ef2833053..6f4263587 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,14 @@ output/ /merged/ /core/automodpack/ -dupes \ No newline at end of file +dupes + +# python + +__pycache__/ +*.egg-info/ +autotester/out/ +autotester/.hmc-cache/ +# build output +/pl/ +/META-INF/ diff --git a/autotester/README.md b/autotester/README.md new file mode 100644 index 000000000..5d4bcc9a0 --- /dev/null +++ b/autotester/README.md @@ -0,0 +1,419 @@ +# AutoModpack Autotester + +Docker-based in-game integration tests for AutoModpack. Spins up a real +Minecraft server + client, runs the full modpack sync flow, and verifies +everything works. + +## How it works + +Every test is defined by a **scenario** — a YAML file that lists **phases** +to execute in order. The framework orchestrates Docker containers +(server via itzg/minecraft-server, client via HeadlessMC) and drives +the in-game UI through a file-based JSON bridge. + +The default scenario (`sync`) launches a server, starts a client, +runs the sync flow (fingerprint → download → verify → restart), +then launches a second client session to verify rejoin behaves correctly +(no re-download, player joins in-game immediately). + +For each target, `run_case()` in `runner.py` creates an isolated Docker +network, starts a server + client container, executes the phase sequence, +captures logs, removes the containers, and returns a result dict that is +aggregated and written to `results.json`. + +## Prerequisites + +- Docker (tested with 27+) +- Python >= 3.11 + [uv](https://docs.astral.sh/uv/) +- Merged AutoModpack artifact in `merged/` (produced by `./gradlew mergeJar`) + +## CLI reference + +``` +uv run autotester build-images [--client-image IMG] [--headlessmc-version VER] +uv run autotester run [--target ID | all] [--scenario ID] [--jobs N] + [--docker-uid UID] [--docker-gid GID] + [--artifact-dir PATH] [--out-dir PATH] + [--client-image IMG] +uv run autotester clean [--out-dir PATH] +``` + +### build-images + +Builds the client Docker image (Java + HeadlessMC). + +| Flag | Default | Description | +|------|---------|-------------| +| `--client-image` | `settings.yaml → images.client` | Tag for the built image | +| `--headlessmc-version` | `settings.yaml → headlessmc.version` | HeadlessMC launcher version | + +### run + +Runs the selected target(s) against a scenario. + +| Flag | Default | Description | +|------|---------|-------------| +| `--target` | `settings.yaml → run.target` (`all`) | Target ID from `targets.yaml`, or `all` | +| `--scenario` | `settings.yaml → run.scenario` (`sync`) | Scenario name (stem of `scenarios/*.yaml`) | +| `--jobs` | `settings.yaml → run.jobs` (`1`) | Max parallel containers (watch Docker resources) | +| `--docker-uid` | `AUTOTEST_DOCKER_UID` or `os.getuid()` | UID for client container process | +| `--docker-gid` | `AUTOTEST_DOCKER_GID` or `os.getgid()` | GID for client container process | +| `--artifact-dir` | `settings.yaml → paths.artifactDir` (`merged/`) | Directory with merged loader JARs | +| `--out-dir` | `settings.yaml → paths.outDir` (`autotester/out/`) | Output directory for logs and results | +| `--client-image` | `settings.yaml → images.client` | Client Docker image tag | + +**Ctrl+C behavior:** +- First Ctrl+C cancels queued (not-yet-started) tests, waits for running + containers to be cleaned up by their `finally` blocks, then writes partial + `results.json` and exits with code 1. +- Second Ctrl+C calls `os._exit(1)` immediately (force kill). + +### clean + +Removes the output directory entirely. + +## Output layout + +Each run produces a timestamped directory under `--out-dir`: + +``` +/ +├── results.json ← aggregated results (see below) +├── --/ ← one per test case (run_case) +│ ├── amp-s-.log ← server container logs +│ ├── amp-c-.log ← client container logs +│ ├── server/ ← server game directory +│ │ ├── mods/automodpack.jar +│ │ ├── automodpack/automodpack-server.json +│ │ ├── automodpack/host-modpack/main/config/amp-autotest-marker.json +│ │ └── automodpack/host-modpack/main/config/amp-autotest-*.txt +│ └── client/ +│ └── game/ ← client game directory +│ ├── mods/automodpack.jar +│ ├── config/fml.toml ← Forge/NeoForge only +│ └── automodpack/ +│ ├── autotest/ ← bridge command/response files +│ └── modpacks/ +│ └── amp-autotest/ ← synced modpack (downloaded) +└── .hmc-cache/ ← seeded HMC cache (persists between runs) +``` + +### results.json schema + +Written when `run` completes (or after Ctrl+C). Structure: + +```json +{ + "ok": false, + "results": [ + { + "target": "1.21.11-fabric", + "scenario": "sync", + "ok": false, + "duration": 142.7, + "error": "Timeout: marker file ... did not appear within 180s" + }, + { + "target": "1.21.11-neoforge", + "scenario": "sync", + "ok": true, + "duration": 98.3 + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `ok` | bool | `true` only if ALL results are ok | +| `results[].target` | string | Target ID from `targets.yaml` | +| `results[].scenario` | string | Scenario ID | +| `results[].ok` | bool | Did this test pass? | +| `results[].duration` | float | Wall-clock seconds for `run_case()` | +| `results[].error` | string? | Failure message (absent on pass) | + +**Exit codes:** `0` = all passed, `1` = any failed or user interrupted. + +## Phase reference + +| Phase | Description | Timeout | +|-------|-------------|---------| +| `launch_server` | Start the server container (itzg/minecraft-server) | N/A | +| `launch_client` | Start/restart client container (HeadlessMC); removes existing container first if re-running | N/A | +| `read_fingerprint` | Extract TLS fingerprint from server logs (regex `certificate fingerprint[: ]+[0-9A-Fa-f:]+`) | `serverStartSeconds` (default 180s) | +| `wait_server` | Wait for `Done (` in server logs | `serverStartSeconds` (default 180s) | +| `wait_bridge` | Poll `bridge-state.json` for existence, then ping the bridge | `clientStartSeconds` (default 180s) | +| `click_continue` | Click "Continue" on Forge/NeoForge welcome/snooper screens until TitleScreen appears | 30s | +| `connect` | Bridge `connect` to server; retries on failure (TitleScreen → retry, ConnectScreen stuck → retry, other → success) | 90s | +| `wait_fingerprint` | Wait for `FingerprintVerificationScreen` to appear | 180s | +| `accept_fingerprint` | Type fingerprint into EditBox, click Verify, wait for DangerScreen/DownloadScreen/RestartScreen | 20s | +| `skip_fingerprint` | Skip verification (for versions without EditBox): click Skip, type "I accept the risk", click active Skip button | 30s | +| `wait_danger` | Wait for `DangerScreen` to appear | 90s | +| `click_confirm` | Click the last active Button on current screen (Confirm on DangerScreen) | 5s | +| `wait_download` | Wait for modpack marker file to appear under `modpacks/{name}/` | `downloadFileSeconds` (default 180s) | +| `verify_files` | Check all `serverFiles.files[]` exist under synced modpack | 120s | +| `verify_mods` | Check all `serverFiles.expectedMods[]` glob patterns match installed jars | 120s | +| `click_restart` | If RestartScreen appears within 20s, click Restart/Close/Quit button, wait for client exit | wait_exit: 90s | +| `quit` | If container is running, bridge `quit` command | bridge: 30s | +| `wait_join` | Poll for null `screenClass` (player in-game, no modal screens) | `rejoinSeconds` (default 90s) | + +## Bridge protocol + +The autotester drives Minecraft's UI through a file-based JSON bridge. +The bridge reads commands from a file and writes responses. + +**Request:** `{game_dir}/automodpack/autotest/bridge-command.json` +```json +{"token": "...", "op": "get_screen"} +{"token": "...", "op": "click", "widgetId": 42} +{"token": "...", "op": "click", "selector": {"text": "Continue"}} +{"token": "...", "op": "set_text", "selector": {"type": "EditBox", "index": 0}, "text": "AB:CD:..."} +{"token": "...", "op": "verify_fingerprint", "fingerprint": "AB:CD:..."} +{"token": "...", "op": "connect", "host": "amp-s-...", "port": 25565} +{"token": "...", "op": "set_screen"} +{"token": "...", "op": "get_widgets"} +{"token": "...", "op": "quit"} +{"token": "...", "op": "ping"} +``` + +**Response:** `bridge-response.json` +```json +{"ok": true, "screenClass": "FingerprintVerificationScreen", "widgets": [...]} +{"ok": false, "error": "No such widget"} +``` + +The command file is written atomically (`.tmp` + `rename`). The client +polls for it, executes `op`, and writes the response. The Python bridge +client (`bridge.py → BridgeClient.request()`) polls for the response +file for up to 30 seconds, sleeping 50ms between polls. + +Available operations: + +| `op` | Payload | Response | +|------|---------|----------| +| `ping` | — | `{"ok": true}` | +| `get_screen` | — | `{"ok": true, "screenClass": "..."}` | +| `get_widgets` | — | `{"ok": true, "screenClass": "...", "widgets": [{"id": N, "type": "...", "text": "...", "active": bool}]}` | +| `click` | `widgetId` or `selector` (`{text: "..."}`) | `{"ok": true}` | +| `set_text` | `selector` + `text` | `{"ok": true}` | +| `set_screen` | — | Clear the current screen (used to abort a stuck connection) | +| `connect` | `host`, `port` | `{"ok": true}` | +| `verify_fingerprint` | `fingerprint` | `{"ok": true}` — types fingerprint + clicks Verify | +| `quit` | — | `{"ok": true}` — calls `Minecraft.getInstance().stop()` off the main thread | + +## Scenario YAML reference + +```yaml +# Required +id: my-test # Scenario identifier +flow: # Phase sequence (ordered) + - launch_server + - wait_server + - launch_client + - wait_bridge + - ... + +# Optional +description: | # Human-readable (not used by code) + What this scenario does + +topology: + server: + type: FABRIC # Override engine type (default: settings.yaml → serverTypes) + image: itzg/minecraft-server # Override server image + memory: 4G # Container memory (default: 2G) + env: # Extra env vars (merged with settings.yaml → server.env) + ENABLE_ROLLING_LOGS: "false" + modrinth: + projects: # Modrinth project slugs (ferrite-core? = optional dependency) + - ferrite-core? + projectsByLoader: # Per-loader project list + fabric: [sodium] + version: "1.21" # Modrinth version filter + versionType: release # Modrinth version type + dependencies: true # Auto-include dependencies + serverCache: + enabled: true # Default: true + clean: false # Purge volume before each run + +serverFiles: + modpackName: amp-autotest # Modpack namespace + marker: config/amp-autotest-marker.json # Path that signals "sync complete" + expectedMods: # Glob patterns for verify_mods phase + - "ferritecore*.jar" + files: # Files written to host-modpack/main/ (synced to client) + - path: config/test-file.txt + content: "hello\n" + +timeouts: # Override settings.yaml timeouts per-scenario + serverStartSeconds: 300 + clientStartSeconds: 300 + downloadFileSeconds: 300 + rejoinSeconds: 180 +``` + +### scenarios/ available + +| File | ID | Flow summary | +|------|----|-------------| +| `sync.yaml` | `sync` | Full end-to-end: server+boot → fingerprint → download → verify → restart → rejoin → in-game check | +| `download-only.yaml` | `download-only` | Same as sync but skips restart and rejoin (faster debug iteration) | + +## settings.yaml reference + +```yaml +paths: + artifactDir: merged # Artifact directory (relative to repo root) + outDir: autotester/out # Test output directory + +images: + server: itzg/minecraft-server # Server Docker image + client: automodpack-autotest-client:local # Client Docker image + serverTagTemplate: "java{java}" # Tag template (e.g. itzg/minecraft-server:java21) + +run: + target: all # Default --target + scenario: sync # Default --scenario + jobs: 4 # Default --jobs + retryMax: 0 # Not implemented (reserved) + +server: + memory: 2G # Default container RAM + env: # Default env vars for itzg/minecraft-server + EULA: "TRUE" + ONLINE_MODE: "FALSE" + DIFFICULTY: "peaceful" + +serverTypes: # Maps loader → TYPE env var + fabric: FABRIC + forge: FORGE + neoforge: NEOFORGE + +headlessmc: + version: "2.9.0" # HeadlessMC launcher version in client image + +timeouts: + serverStartSeconds: 180 # Max wait for server "Done (" + clientStartSeconds: 180 # Max wait for bridge to appear + downloadFileSeconds: 180 # Max wait for download marker + rejoinSeconds: 90 # Max wait for in-game rejoin + +serverCache: # Docker volume for server JARs + enabled: true + volumePrefix: "amp-server-cache" # Volume name: {prefix}-{target.id} + clean: false + +automodpack: + config: # Written to server's automodpack-server.json + DO_NOT_CHANGE_IT: 2 + modpackHost: true + generateModpackOnStart: true + syncedFiles: + - "/mods/*.jar" + - "/kubejs/**" + - "!/kubejs/server_scripts/**" + ... +``` + +## targets.yaml reference + +```yaml +defaults: + fabricLoader: "0.17.3" # Default Fabric loader version + java: 21 # Default Java version + +targets: + - id: "1.21.11-fabric" # Unique target ID (used with --target) + minecraft: "1.21.11" # Minecraft version + loader: "fabric" # fabric | forge | neoforge + java: 21 # Java version (17 | 21 | 25) + fabricLoader: "0.17.3" # Fabric loader version (required for fabric) + forgeVersion: "47.3.0" # Forge version (required for forge) + neoforgeVersion: "21.11.37-beta" # NeoForge version (required for neoforge) +``` + +Artifact discovery: the runner globs `{artifactDir}/automodpack-mc{minecraft}-{loader}-*.jar` +and uses the newest match. + +## Docker networking + +Each test case creates an isolated Docker bridge network. The server is +reachable from the client by its container name. The network and both +containers are destroyed in the `finally` block of `run_case()`. + +### Client container (HeadlessMC) + +Built via `build-images`. Uses `run-headlessmc-client` entrypoint which: +1. Selects the correct Java binary (`java-{version}-openjdk-amd64`) +2. Downloads HeadlessMC launcher if missing +3. Launches Minecraft with AutoModpack + +Environment: + +| Variable | Description | +|----------|-------------| +| `AM_AUTOTEST_BRIDGE_TOKEN` | Auth token; must match bridge request `token` | +| `AM_AUTOTEST_GAME_DIR` | Path to client game directory (mounted from host) | +| `AM_AUTOTEST_HMC_DIR` | HeadlessMC cache directory (mounted from host) | +| `AUTOTEST_DOCKER_UID` | Container UID (for file ownership) | +| `AUTOTEST_DOCKER_GID` | Container GID (for file ownership) | + +### Server container (itzg/minecraft-server) + +Uses a Docker volume (`amp-server-cache-{target.id}`) to persist server JARs +between runs. The host `mods/` and `automodpack/` directories are bind-mounted +into `/data/mods` and `/data/automodpack` so the server sees the test's +automodpack.jar and config. + +## Caching + +- **Server JARs**: Docker volume `amp-server-cache-{target.id}`. Disable by + setting `serverCache.enabled: false` in `settings.yaml`. +- **Client HMC cache**: The `.hmc-cache/` directory is seeded from the + previous run's cache via `cp --reflink=auto` before each test, avoiding + re-download of Minecraft jars. It lives at `{out_dir}/../.hmc-cache/`. + +## Environment variables + +| Variable | Default | Used in | +|----------|---------|---------| +| `AUTOTEST_DOCKER_UID` | `os.getuid()` | Client container `-u` flag | +| `AUTOTEST_DOCKER_GID` | `os.getgid()` | Client container `-u` flag | + +Set these when running as root or inside a CI container where uid/gid +mismatches would cause permission issues on bind-mounted volumes. + +## Adding a target + +1. Add an entry to `targets.yaml` with `id`, `minecraft`, `loader`, + `java`, and the appropriate loader version field. +2. The `build-images` step is already version-agnostic — any Java version + in the matrix will work as long as it's between 17 and 25 and available + in the container (`java-{version}-openjdk-amd64`). +3. Add the target ID to `.github/workflows/ingame-tests.yml` matrix to + run it in CI. + +## Adding a scenario + +Create `scenarios/my-test.yaml`: + +```yaml +id: my-test +flow: [launch_server, wait_server, launch_client, wait_bridge, quit] +topology: + server: + modrinth: + projects: [ferrite-core] + dependencies: true +``` + +Then run: `uv run autotester run --scenario my-test` + +## CI (GitHub Actions) + +`.github/workflows/ingame-tests.yml` runs each target as a separate job +in a matrix. Each job: checks out repo → Build AutoModpack → Build client +image → Run autotest → Upload artifacts. Artifacts include the full +`autotester/out/` directory (logs + results.json). The workflow can be +triggered manually via `workflow_dispatch` with overridable `scenario`, +`target`, and `jobs` inputs. diff --git a/autotester/automodpack_autotester/__init__.py b/autotester/automodpack_autotester/__init__.py new file mode 100644 index 000000000..54ff7ceb8 --- /dev/null +++ b/autotester/automodpack_autotester/__init__.py @@ -0,0 +1,5 @@ +"""Docker-first AutoModpack in-game test harness.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/autotester/automodpack_autotester/bridge.py b/autotester/automodpack_autotester/bridge.py new file mode 100644 index 000000000..191fc5eb8 --- /dev/null +++ b/autotester/automodpack_autotester/bridge.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class BridgeClient: + game_dir: Path + token: str + + def request(self, op: str, **payload) -> dict: + autotest_dir = self.game_dir / "automodpack" / "autotest" + autotest_dir.mkdir(parents=True, exist_ok=True) + cmd = autotest_dir / "bridge-command.json" + rsp = autotest_dir / "bridge-response.json" + rsp.unlink(missing_ok=True) + tmp = cmd.with_suffix(".tmp") + tmp.write_text( + json.dumps({"token": self.token, "op": op, **payload}), encoding="utf-8" + ) + tmp.rename(cmd) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if rsp.exists(): + data = json.loads(rsp.read_text(encoding="utf-8")) + rsp.unlink(missing_ok=True) + if not data.get("ok"): + raise RuntimeError(f"Bridge error on '{op}': {data.get('error', data)}") + return data + time.sleep(0.05) + raise TimeoutError(f"Bridge did not respond to '{op}' after 30s") diff --git a/autotester/automodpack_autotester/cli.py b/autotester/automodpack_autotester/cli.py new file mode 100644 index 000000000..f23b4445b --- /dev/null +++ b/autotester/automodpack_autotester/cli.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from .config import REPO_ROOT, ROOT, load_scenarios, load_settings, load_targets +from .docker import Docker +from .runner import run_case + +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + + +def _resolve_settings_path(s: dict, key: str, default: str) -> Path: + raw = s.get("paths", {}).get(key, default) + p = Path(str(raw)) + return (REPO_ROOT / p).resolve() if not p.is_absolute() else p.resolve() + + +def _kill_amp_containers() -> None: + r = subprocess.run( + ["docker", "ps", "-q", "-a", "--filter", "name=amp-"], + capture_output=True, text=True, check=False, + ) + for cid in r.stdout.strip().split(): + if cid: + subprocess.run(["docker", "rm", "-f", cid], check=False, capture_output=True) + r = subprocess.run( + ["docker", "network", "ls", "-q", "--filter", "name=amp-"], + capture_output=True, text=True, check=False, + ) + for nid in r.stdout.strip().split(): + if nid: + subprocess.run(["docker", "network", "rm", nid], check=False, capture_output=True) + + +def main(argv: list[str] | None = None) -> int: + import argparse + + p = argparse.ArgumentParser(prog="autotester") + sub = p.add_subparsers(dest="command", required=True) + + build = sub.add_parser("build-images") + build.add_argument("--client-image") + build.add_argument("--headlessmc-version") + + run_p = sub.add_parser("run") + run_p.add_argument("--target") + run_p.add_argument("--scenario") + run_p.add_argument("--jobs", type=int, default=1) + run_p.add_argument("--docker-uid", type=int) + run_p.add_argument("--docker-gid", type=int) + run_p.add_argument("--artifact-dir", type=Path) + run_p.add_argument("--out-dir", type=Path) + run_p.add_argument("--client-image") + + clean = sub.add_parser("clean") + clean.add_argument("--out-dir", type=Path) + + args = p.parse_args(argv) + + if args.command == "build-images": + s = load_settings() + ver = args.headlessmc_version or str( + s.get("headlessmc", {}).get("version", "2.9.0") + ) + img = args.client_image or str( + s.get("images", {}).get("client", "automodpack-autotest-client:local") + ) + Docker().build( + img, + ROOT / "docker" / "client" / "Dockerfile", + ROOT / "docker" / "client", + {"HEADLESSMC_VERSION": ver}, + ) + return 0 + + if args.command == "clean": + s = load_settings() + out_dir = ( + _resolve_settings_path(s, "outDir", "out") + if not args.out_dir + else args.out_dir.resolve() + ) + shutil.rmtree(out_dir, ignore_errors=True) + return 0 + + # --- run --- + s = load_settings() + rc = s.get("run", {}) + + if args.docker_uid is not None: + os.environ["AUTOTEST_DOCKER_UID"] = str(args.docker_uid) + elif rc.get("dockerUid") is not None: + os.environ.setdefault("AUTOTEST_DOCKER_UID", str(rc["dockerUid"])) + if args.docker_gid is not None: + os.environ["AUTOTEST_DOCKER_GID"] = str(args.docker_gid) + elif rc.get("dockerGid") is not None: + os.environ.setdefault("AUTOTEST_DOCKER_GID", str(rc["dockerGid"])) + + targets = load_targets() + scenarios = load_scenarios() + scenario = scenarios.get(args.scenario or rc.get("scenario", "sync")) + selected = ( + list(targets.values()) + if not args.target or args.target == "all" + else [targets[args.target]] + ) + if not selected: + print("No targets", file=sys.stderr) + return 1 + + out_dir = ( + _resolve_settings_path(s, "outDir", "out") + if not args.out_dir + else args.out_dir.resolve() + ) + artifact_dir = ( + _resolve_settings_path(s, "artifactDir", "merged") + if not args.artifact_dir + else args.artifact_dir.resolve() + ) + client_image = args.client_image or str( + s.get("images", {}).get("client", "automodpack-autotest-client:local") + ) + out_dir.mkdir(parents=True, exist_ok=True) + + results: dict = {} + interrupted = False + try: + executor = ThreadPoolExecutor( + max_workers=max(1, args.jobs or rc.get("jobs", 1)) + ) + try: + task_map = { + executor.submit( + run_case, + t, + scenario, + out_dir=out_dir, + artifact_dir=artifact_dir, + client_image=client_image, + settings=s, + ): t + for t in selected + } + for f in as_completed(task_map): + r = f.result() + results[r["target"]] = r + print( + f"{'PASS' if r['ok'] else 'FAIL'} {r['target']} {r.get('duration', 0):.1f}s" + ) + if r.get("error"): + print(f" {r['error']}", file=sys.stderr) + + except KeyboardInterrupt: + interrupted = True + print("\nInterrupted, cleaning up containers...", file=sys.stderr) + for ff in task_map: + ff.cancel() + _kill_amp_containers() + print("Cleanup complete.", file=sys.stderr) + + finally: + executor.shutdown(wait=False) + ok = all(r.get("ok", False) for r in results.values()) + (out_dir / "results.json").write_text( + json.dumps({"ok": ok, "results": list(results.values())}, indent=2) + ) + if interrupted: + return 1 + return 0 if ok else 1 + + except KeyboardInterrupt: + print("Force exit.", file=sys.stderr) + return 1 diff --git a/autotester/automodpack_autotester/config.py b/autotester/automodpack_autotester/config.py new file mode 100644 index 000000000..fc0b523cc --- /dev/null +++ b/autotester/automodpack_autotester/config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +def _find_root() -> Path: + cwd = Path.cwd().resolve() + for p in (cwd, cwd / "autotester", cwd.parent / "autotester"): + if (p / "settings.yaml").is_file(): + return p + raise FileNotFoundError( + "settings.yaml not found — run from project root or autotester/" + ) + + +ROOT = _find_root() +REPO_ROOT = ROOT.parent + + +def load_yaml(path: Path) -> dict: + with path.open() as f: + return yaml.safe_load(f) or {} + + +def load_settings() -> dict: + return load_yaml(ROOT / "settings.yaml") + + +@dataclass +class Target: + id: str + minecraft: str + loader: str + java: int + fabric_loader: str | None = None + forge_version: str | None = None + neoforge_version: str | None = None + + +def load_targets() -> dict[str, Target]: + raw = load_yaml(ROOT / "targets.yaml") + d = raw.get("defaults", {}) + targets = [] + for item in raw.get("targets", []): + targets.append( + Target( + id=item["id"], + minecraft=item["minecraft"], + loader=item["loader"], + java=item.get("java", d.get("java", 21)), + fabric_loader=item.get("fabricLoader", d.get("fabricLoader")), + forge_version=item.get("forgeVersion", d.get("forgeVersion")), + neoforge_version=item.get("neoforgeVersion", d.get("neoforgeVersion")), + ) + ) + return {t.id: t for t in targets} + + +def load_scenarios() -> dict[str, dict]: + return {f.stem: load_yaml(f) for f in sorted((ROOT / "scenarios").glob("*.yaml"))} diff --git a/autotester/automodpack_autotester/docker.py b/autotester/automodpack_autotester/docker.py new file mode 100644 index 000000000..83f6bc873 --- /dev/null +++ b/autotester/automodpack_autotester/docker.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + + +@dataclass +class Container: + name: str + id: str + + +def _run( + args: list[str], *, check: bool = True, capture: bool = False +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run(args, check=check, text=True, capture_output=capture) + except subprocess.CalledProcessError as e: + msg = str(e) + if e.stderr: + msg += f"\n stderr: {e.stderr.strip()}" + raise RuntimeError(msg) from e + + +def _output(args: list[str]) -> str: + cp = _run(args, capture=True) + return (cp.stdout or "") + (cp.stderr or "") + + +class Docker: + def build( + self, + tag: str, + dockerfile: Path, + context: Path, + build_args: Mapping[str, str] | None = None, + ) -> None: + args = ["docker", "build", "-t", tag, "-f", str(dockerfile)] + for k, v in (build_args or {}).items(): + args += ["--build-arg", f"{k}={v}"] + _run(args + [str(context)]) + + def create_network(self, name: str) -> None: + _run(["docker", "network", "rm", name], check=False, capture=True) + _run(["docker", "network", "create", name]) + + def remove_network(self, name: str) -> None: + _run(["docker", "network", "rm", name], check=False, capture=True) + + def ensure_volume(self, name: str) -> None: + _run(["docker", "volume", "create", name]) + + def remove_volume(self, name: str) -> None: + _run(["docker", "volume", "rm", name], check=False, capture=True) + + def remove_container(self, name: str) -> None: + _run(["docker", "rm", "-f", name], check=False, capture=True) + + def run_detached( + self, + *, + name: str, + image: str, + network: str, + env: Mapping[str, str], + mounts: list[tuple[Path | str, str, bool]], + command: list[str] | None = None, + user: str | None = None, + ) -> Container: + args = ["docker", "run", "-d", "--name", name, "--network", network] + if user: + args += ["-u", user] + for k, v in env.items(): + args += ["-e", f"{k}={v}"] + for host, container, readonly in mounts: + args += ["-v", f"{host}:{container}:{'ro' if readonly else 'rw'}"] + args.append(image) + if command: + args += command + return Container(name=name, id=_output(args).strip()) + + def logs(self, name: str) -> str: + return _output(["docker", "logs", name]) + + def inspect(self, name: str) -> dict: + return json.loads(_output(["docker", "inspect", name]))[0] + + def wait_for_log(self, name: str, needle: str, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if needle in self.logs(name): + return + self.assert_running(name) + time.sleep(2) + raise TimeoutError(f"Timeout waiting for {needle!r} in {name}") + + def assert_running(self, name: str) -> None: + state = self.inspect(name).get("State", {}) + if not state.get("Running", False): + raise RuntimeError( + f"Container {name} exited (code={state.get('ExitCode', -1)}, error={state.get('Error', '')})" + ) + + def wait_exited(self, name: str, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not self.inspect(name).get("State", {}).get("Running", False): + return + time.sleep(1) + raise TimeoutError(f"Timeout waiting for {name} to exit") diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py new file mode 100644 index 000000000..54be63d6f --- /dev/null +++ b/autotester/automodpack_autotester/runner.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +import json +import logging +import os +import re +import secrets +import shutil +import subprocess +import time +from collections.abc import Callable +from fnmatch import fnmatch +from pathlib import Path + +from .bridge import BridgeClient +from .config import Target +from .docker import Docker + +logger = logging.getLogger(__name__) + +PHASES: dict[str, Callable] = {} + + +def _reg(name: str) -> Callable: + """Decorator: register a function in PHASES under the given name.""" + def wrapper(fn: Callable) -> Callable: + PHASES[name] = fn + return fn + return wrapper + + +def _uid(): + return int(os.environ.get("AUTOTEST_DOCKER_UID", os.getuid())) + + +def _gid(): + return int(os.environ.get("AUTOTEST_DOCKER_GID", os.getgid())) + + +def _load_ver(t): + return t.fabric_loader or t.forge_version or t.neoforge_version or "" + + +def _bridge_state(ctx): + return ctx["game_dir"] / "automodpack" / "autotest" / "bridge-state.json" + + +def _await(pred, timeout, msg): + dl = time.monotonic() + timeout + while time.monotonic() < dl: + r = pred() + if r is not None: + return r + time.sleep(0.5) + raise TimeoutError(msg) + + +def run_case( + target: Target, + scenario: dict, + *, + out_dir: Path, + artifact_dir: Path, + client_image: str, + settings: dict, +) -> dict: + started = time.monotonic() + case_dir = out_dir / f"{target.id}-{int(time.time())}-{secrets.token_hex(3)}" + server_dir = case_dir / "server" + game_dir = case_dir / "client" / "game" + cache_dir = case_dir / "hmc-cache" + net_name = f"amp-{secrets.token_hex(4)}"[:63] + srv_name = f"amp-s-{secrets.token_hex(4)}"[:63] + cli_name = f"amp-c-{secrets.token_hex(4)}"[:63] + token = secrets.token_hex(16) + docker = Docker() + ctx = dict(locals()) + + for d in (server_dir, game_dir, cache_dir): + d.mkdir(parents=True, exist_ok=True) + + try: + pattern = f"automodpack-mc{target.minecraft}-{target.loader}-*.jar" + matches = sorted(artifact_dir.glob(pattern)) + if not matches: + raise FileNotFoundError(f"No artifact for {target.id} in {artifact_dir}") + ctx["artifact"] = matches[-1].resolve() + + sf = scenario.get("serverFiles", {}) + ctx["modpack_name"] = str(sf.get("modpackName", "amp-autotest")) + ctx["marker_rel"] = Path( + str(sf.get("marker", "config/amp-autotest-marker.json")) + ) + ctx["scenario_files"] = [ + (Path(str(f["path"])), str(f.get("content", ""))) + for f in sf.get("files", []) + ] + ctx["expected_mods"] = [str(m) for m in sf.get("expectedMods", [])] + + _prepare_server(ctx, target, settings) + _seed_cache(ctx) + docker.create_network(net_name) + + flow = scenario.get("flow", []) + if not flow: + raise ValueError("scenario has no 'flow' list") + for phase_name in flow: + fn = PHASES.get(phase_name) + if not fn: + raise ValueError(f"unknown phase: {phase_name!r}") + logger.info("[%s] Phase: %s", target.id, phase_name) + fn(ctx) + + return { + "target": target.id, + "scenario": scenario.get("id", "?"), + "ok": True, + "duration": time.monotonic() - started, + } + + except Exception as e: + return { + "target": target.id, + "scenario": scenario.get("id", "?"), + "ok": False, + "duration": time.monotonic() - started, + "error": str(e), + } + + finally: + for name in [cli_name, srv_name]: + try: + logs = docker.logs(name) + if logs: + (case_dir / f"{name}.log").write_text( + logs, encoding="utf-8", errors="replace" + ) + except Exception: + pass + try: + docker.remove_container(name) + except Exception: + logger.warning("Failed to remove container %s", name) + docker.remove_network(net_name) + + +# === infrastructure (not flow phases) === + + +def _prepare_server(ctx, target, settings): + """Write server config and test files to server_dir BEFORE launch.""" + srv_dir = ctx["server_dir"] + (srv_dir / "mods").mkdir(parents=True, exist_ok=True) + shutil.copy2(ctx["artifact"], srv_dir / "mods" / "automodpack.jar") + cfg = dict(settings.get("automodpack", {}).get("config", {})) + cfg["modpackName"] = ctx["modpack_name"] + cfg["acceptedLoaders"] = [target.loader] + (srv_dir / "automodpack").mkdir(parents=True, exist_ok=True) + (srv_dir / "automodpack" / "automodpack-server.json").write_text( + json.dumps(cfg, indent=2) + ) + host_root = srv_dir / "automodpack" / "host-modpack" / "main" + host_root.mkdir(parents=True, exist_ok=True) + (host_root / ctx["marker_rel"]).parent.mkdir(parents=True, exist_ok=True) + (host_root / ctx["marker_rel"]).write_text( + json.dumps({"marker": ctx["modpack_name"]}) + "\n" + ) + for rel, content in ctx["scenario_files"]: + f = host_root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + + +def _seed_cache(ctx): + """Pre-populate HMC cache from previous run (client side only).""" + cache_seed = (ctx["out_dir"].parent / ".hmc-cache").resolve() + if cache_seed.exists() and cache_seed != ctx["cache_dir"]: + try: + subprocess.run( + [ + "cp", + "-ra", + "--reflink=auto", + f"{cache_seed}/.", + f"{ctx['cache_dir']}/", + ], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError: + shutil.copytree( + cache_seed, + ctx["cache_dir"], + copy_function=shutil.copy2, + dirs_exist_ok=True, + ) + + +def _launch_server(ctx, target, scenario, settings): + """Start the Minecraft server container. + + Server type (forge/fabric/etc) comes from: + 1. topology.server.type in the scenario YAML + 2. serverTypes.{loader} in settings.yaml + 3. Auto-detected by itzg/minecraft-server if neither set. + + Server env vars are merged from (lower wins): + - settings.yaml → server.env (defaults) + - scenario YAML → topology.server.env (overrides) + + Server files/libraries are cached in a Docker volume + (amp-server-cache-{target.id}) that persists between runs. + """ + topo = scenario.get("topology", {}).get("server", {}) + srv_type = topo.get("type") or settings.get("serverTypes", {}).get(target.loader) + if not srv_type: + raise ValueError(f"No server type for {target.loader}") + + env = dict(settings.get("server", {}).get("env", {})) + env.update({ + "TYPE": str(srv_type), + "VERSION": target.minecraft, + "MEMORY": str(topo.get("memory", "2G")), + }) + for k, v in [ + ("fabric_loader", "FABRIC_LOADER_VERSION"), + ("forge_version", "FORGE_VERSION"), + ("neoforge_version", "NEOFORGE_VERSION"), + ]: + val = getattr(target, k, None) + if val: + env[v] = val + env.update({str(k): str(v) for k, v in (topo.get("env", {}) or {}).items()}) + mr = topo.get("modrinth", {}) + if mr: + projs = list( + dict.fromkeys( + str(p).strip() + for p in ( + list(mr.get("projects", [])) + + list( + (mr.get("projectsByLoader", {}) or {}).get(target.loader, []) + or [] + ) + ) + if p + ) + ) + if projs: + env["MODRINTH_PROJECTS"] = ",".join(projs) + if mr.get("version"): + env["MODRINTH_VERSION"] = str(mr["version"]) + if mr.get("versionType"): + env["MODRINTH_PROJECTS_DEFAULT_VERSION_TYPE"] = str(mr["versionType"]) + sc = topo.get("serverCache", {}) or settings.get("serverCache", {}) + if sc.get("enabled", True): + vol = f"{sc.get('volumePrefix', 'amp-server-cache')}-{target.id}" + if sc.get("clean", False): + ctx["docker"].remove_volume(vol) + ctx["docker"].ensure_volume(vol) + mounts = [(vol, "/data", False)] + for sub in ("mods", "automodpack"): + (ctx["server_dir"] / sub).mkdir(parents=True, exist_ok=True) + mounts.append((ctx["server_dir"] / sub, f"/data/{sub}", False)) + else: + mounts = [(ctx["server_dir"], "/data", False)] + img = str( + topo.get("image") + or settings.get("images", {}).get("server", "itzg/minecraft-server") + ) + if ":" not in img: + img = f"{img}:{str(settings.get('images', {}).get('serverTagTemplate', 'java{java}')).format(java=target.java)}" + ctx["docker"].run_detached( + name=ctx["srv_name"], image=img, network=ctx["net_name"], env=env, mounts=mounts + ) + + +def _launch_client(ctx, target, client_image): + """Start a fresh client container. + + Client uses an HMC cache directory (hmc-cache/) mounted at /work/hmc-cache. + This cache is seeded from the previous run via _seed_cache above. + """ + game_dir = ctx["game_dir"] + (game_dir / "mods").mkdir(parents=True, exist_ok=True) + shutil.copy2(ctx["artifact"], game_dir / "mods" / "automodpack.jar") + if target.loader in ("forge", "neoforge"): + (game_dir / "config").mkdir(parents=True, exist_ok=True) + (game_dir / "config" / "fml.toml").write_text( + 'disableConfigWatcher = false\nearlyWindowControl = false\nmaxThreads = -1\nversionCheck = true\ndefaultConfigPath = "defaultconfigs"\ndisableOptimizedDFU = true\nearlyWindowProvider = "fmlearlywindow"\nearlyWindowWidth = 854\nearlyWindowHeight = 480\nearlyWindowMaximized = false\ndebugOpenGl = false\nearlyWindowFBScale = 1\nearlyWindowSkipGLVersions = []\nearlyWindowSquir = false\nearlyLoadingScreenTheme = ""\ndependencyOverrides = {}\n' + ) + ctx["docker"].run_detached( + name=ctx["cli_name"], + image=client_image, + network=ctx["net_name"], + env={ + "AM_AUTOTEST_BRIDGE_TOKEN": ctx["token"], + "AM_AUTOTEST_GAME_DIR": "/work/game", + "AM_AUTOTEST_HMC_DIR": "/work/hmc-cache", + }, + mounts=[ + (game_dir, "/work/game", False), + (ctx["cache_dir"], "/work/hmc-cache", False), + ], + command=[ + "/opt/automodpack/run-headlessmc-client", + target.loader, + target.minecraft, + "localhost", + "25565", + str(target.java), + _load_ver(target), + ], + user=f"{_uid()}:{_gid()}", + ) + time.sleep(1) + ctx["docker"].assert_running(ctx["cli_name"]) + + +def _wait_server(ctx, target, scenario, settings): + to = scenario.get("timeouts", {}) or settings.get("timeouts", {}) + ctx["docker"].wait_for_log( + ctx["srv_name"], "Done (", timeout=float(to.get("serverStartSeconds", 180)) + ) + + +# === flow phases — each does exactly ONE thing === + + +@_reg("wait_bridge") +def _phase_wait_bridge(ctx): + """Poll bridge-state.json until it exists, then ping the bridge.""" + if "bridge" in ctx: + return + ctx["bridge"] = BridgeClient(ctx["game_dir"], ctx["token"]) + to = float(ctx["scenario"].get("timeouts", {}).get("clientStartSeconds", 180)) + dl = time.monotonic() + to + while time.monotonic() < dl: + try: + ctx["docker"].assert_running(ctx["cli_name"]) + except RuntimeError as e: + logs = ctx["docker"].logs(ctx["cli_name"]) + raise TimeoutError( + f"Client exited before bridge: {e}\n--- logs ---\n{logs[-2000:]}" + ) + if _bridge_state(ctx).exists(): + try: + ctx["bridge"].request("ping") + return + except Exception: + pass + time.sleep(1) + raise TimeoutError(f"Bridge for {ctx['target'].id} did not become available within {to}s") + + +@_reg("click_continue") +def _phase_click_continue(ctx): + """Dismiss any interstitial screen by clicking 'Continue' until TitleScreen appears.""" + bridge = ctx["bridge"] + dl = time.monotonic() + 30 + while time.monotonic() < dl: + try: + r = bridge.request("get_widgets") + except (TimeoutError, RuntimeError): + time.sleep(1) + continue + if "TitleScreen" in str(r.get("screenClass", "")) or "class_442" in str( + r.get("screenClass", "") + ): + return + if any("Continue" in str(w.get("text", "")) for w in r.get("widgets", [])): + try: + bridge.request("click", selector={"text": "Continue"}) + except (TimeoutError, RuntimeError): + pass + time.sleep(1) + continue + time.sleep(0.5) + + +@_reg("read_fingerprint") +def _phase_read_fingerprint(ctx): + """Read TLS certificate fingerprint from server container logs. + + Polls for up to serverStartSeconds (default 180s) because the server + starts in parallel with this phase. The fingerprint line appears + early in startup — usually within seconds — but we keep waiting + for slow starts. + """ + to = float(ctx["scenario"].get("timeouts", {}).get("serverStartSeconds", 180)) + dl = time.monotonic() + to + while time.monotonic() < dl: + logs = ctx["docker"].logs(ctx["srv_name"]) + for line in logs.splitlines(): + m = re.search( + r"(?:certificate\s+)?fingerprint[:\s]+([0-9A-Fa-f:]+)", line, re.IGNORECASE + ) + if m: + ctx["fingerprint"] = m.group(1) + return + time.sleep(1) + raise RuntimeError("No TLS fingerprint found in server logs") + + +@_reg("connect") +def _phase_connect(ctx): + """Connect the client to the server, retrying if connection fails or sticks.""" + bridge = ctx["bridge"] + host = ctx["srv_name"] + deadline = time.monotonic() + 90 + + # Both Yarn and MojMap class-name checks (Yarn: class_397=ConnectScreen, class_442=TitleScreen) + _TITLE = ("TitleScreen", "class_442") + _CONNECT = ("ConnectScreen", "class_397") + + while time.monotonic() < deadline: + bridge.request("connect", host=host, port=25565) + # Poll up to 15s: TitleScreen → failure (retry); not ConnectScreen → success; stuck on ConnectScreen → retry + poll_dl = time.monotonic() + 15 + while time.monotonic() < poll_dl: + screen = str(bridge.request("get_screen").get("screenClass") or "") + if any(n in screen for n in _TITLE): + break # connection failed → retry + if not any(n in screen for n in _CONNECT): + return # no longer connecting → success (FingerprintScreen, DangerScreen, etc.) + time.sleep(0.5) + # Cancel the stuck connection by reopening the title screen before retrying + bridge.request("set_screen") + time.sleep(1) + raise RuntimeError("Could not connect after multiple attempts") + + +@_reg("wait_fingerprint") +def _phase_wait_fingerprint(ctx): + """Wait for the mod's FingerprintVerificationScreen to appear.""" + fp = ctx.get("fingerprint") + if not fp: + raise RuntimeError("No fingerprint — run read_fingerprint phase first") + _await( + lambda: ( + "FingerprintVerificationScreen" + in str(ctx["bridge"].request("get_screen").get("screenClass", "")) + or None + ), + 180, + f"FingerprintVerificationScreen did not appear for {ctx['target'].id} within 180s", + ) + + +@_reg("accept_fingerprint") +def _phase_accept_fingerprint(ctx): + """Type the fingerprint into the EditBox and click Verify (real user flow).""" + fp = ctx.get("fingerprint") + if not fp: + raise RuntimeError("No fingerprint — run read_fingerprint phase first") + ctx["bridge"].request("verify_fingerprint", fingerprint=fp) + _await( + lambda: ( + any( + n in str(ctx["bridge"].request("get_screen").get("screenClass", "")) + for n in ("DangerScreen", "DownloadScreen", "RestartScreen") + ) + or "FingerprintVerificationScreen" + not in str(ctx["bridge"].request("get_screen").get("screenClass", "")) + or None + ), + 20, + "Fingerprint verification did not complete", + ) + + +@_reg("skip_fingerprint") +def _phase_skip_fingerprint(ctx): + """Skip fingerprint verification (for versions without EditBox).""" + bridge = ctx["bridge"] + bridge.request("click", selector={"text": "Skip"}) + _await( + lambda: ( + "SkipVerificationScreen" + in str(bridge.request("get_screen").get("screenClass", "")) + or None + ), + 15, + "Skip screen not shown", + ) + bridge.request( + "set_text", selector={"type": "EditBox", "index": 0}, text="I accept the risk" + ) + dl = time.monotonic() + 30 + while time.monotonic() < dl: + for w in bridge.request("get_widgets").get("widgets", []): + if ( + w.get("type") == "Button" + and "Skip" in str(w.get("text", "")) + and w.get("active", False) + ): + bridge.request("click", selector={"widgetId": w["id"]}) + return + time.sleep(1) + raise RuntimeError("Skip button did not activate") + + +@_reg("wait_danger") +def _phase_wait_danger(ctx): + """Wait for DangerScreen to appear.""" + bridge = ctx["bridge"] + _await( + lambda: ( + "DangerScreen" in str(bridge.request("get_screen").get("screenClass", "")) + or None + ), + 90, + "DangerScreen did not appear within 90s", + ) + + +@_reg("click_confirm") +def _phase_click_confirm(ctx): + """Click the last active Button on the current screen (Confirm on DangerScreen).""" + bridge = ctx["bridge"] + dl = time.monotonic() + 5 + while time.monotonic() < dl: + widgets = bridge.request("get_widgets").get("widgets", []) + if widgets: + break + time.sleep(0.2) + for w in reversed(widgets): + if w.get("type") == "Button" and w.get("active", False): + bridge.request("click", widgetId=int(w.get("id", -1))) + return + raise RuntimeError("No active button on DangerScreen") + + +@_reg("wait_download") +def _phase_wait_download(ctx): + """Wait for the modpack download marker file to appear.""" + marker = ( + ctx["game_dir"] + / "automodpack" + / "modpacks" + / ctx["modpack_name"] + / ctx["marker_rel"] + ) + timeout = float(ctx["scenario"].get("timeouts", {}).get("downloadFileSeconds", 300)) + _await( + lambda: marker if marker.exists() else None, + timeout, + f"Download marker file {marker} did not appear within {timeout}s", + ) + if not marker.exists(): + raise FileNotFoundError(f"Missing marker: {marker}") + + +@_reg("verify_files") +def _phase_verify_files(ctx): + """Check that all expected serverFiles exist in the synced modpack.""" + mp_root = ctx["game_dir"] / "automodpack" / "modpacks" / ctx["modpack_name"] + dl = time.monotonic() + 120 + while time.monotonic() < dl: + if all((mp_root / rel).exists() for rel, _ in ctx["scenario_files"]): + return + time.sleep(2) + missing = [ + str(rel) for rel, _ in ctx["scenario_files"] if not (mp_root / rel).exists() + ] + raise TimeoutError(f"Files missing after sync: {', '.join(missing)}") + + +@_reg("verify_mods") +def _phase_verify_mods(ctx): + """Check that all expected mod patterns match at least one installed jar.""" + if not ctx["expected_mods"]: + return + mp_root = ctx["game_dir"] / "automodpack" / "modpacks" / ctx["modpack_name"] + dl = time.monotonic() + 120 + mod_dir = mp_root / "mods" + while time.monotonic() < dl: + mods = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() + if all(any(fnmatch(m, p) for m in mods) for p in ctx["expected_mods"]): + return + time.sleep(2) + existing = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() + missing = [ + p for p in ctx["expected_mods"] if not any(fnmatch(m, p) for m in existing) + ] + raise TimeoutError(f"Mods missing after sync: {', '.join(missing)}") + + +@_reg("click_restart") +def _phase_click_restart(ctx): + """Wait 20s for RestartScreen; if shown, click Restart. Otherwise continue.""" + bridge = ctx["bridge"] + dl = time.monotonic() + 20 + while time.monotonic() < dl: + try: + screen = bridge.request("get_screen") + except TimeoutError: + continue + if "RestartScreen" in str(screen.get("screenClass", "")): + try: + widgets = bridge.request("get_widgets").get("widgets", []) + action_labels = ("close", "restart", "quit") + for w in reversed(widgets): + txt = str(w.get("text", "")).lower() + if w.get("type") == "Button" and w.get("active", False) and any(l in txt for l in action_labels): + bridge.request("click", widgetId=int(w.get("id", -1))) + break + except RuntimeError: + pass + ctx["docker"].wait_exited(ctx["cli_name"], timeout=90) + return + time.sleep(0.5) + + +@_reg("quit") +def _phase_quit(ctx): + """Disconnect and quit the game if still running.""" + try: + state = ctx["docker"].inspect(ctx["cli_name"]).get("State", {}) + if state.get("Running", False): + ctx["bridge"].request("quit") + except (RuntimeError, TimeoutError): + pass + + +@_reg("launch_server") +def _phase_launch_server(ctx): + """Start the server container.""" + _launch_server(ctx, ctx["target"], ctx["scenario"], ctx["settings"]) + + +@_reg("wait_server") +def _phase_wait_server(ctx): + """Wait for 'Done (' in server logs.""" + _wait_server(ctx, ctx["target"], ctx["scenario"], ctx["settings"]) + + +@_reg("launch_client") +def _phase_launch_client(ctx): + """Start a fresh client container (for rejoin phase).""" + ctx["docker"].remove_container(ctx["cli_name"]) + if "bridge" in ctx: + del ctx["bridge"] + _launch_client(ctx, ctx["target"], ctx["client_image"]) + + +@_reg("wait_join") +def _phase_wait_join(ctx): + """Wait for null screenClass indicating the player is in-game.""" + bridge = ctx["bridge"] + to = float(ctx["scenario"].get("timeouts", {}).get("rejoinSeconds", 180)) + _await( + lambda: ( + "FingerprintVerificationScreen" + not in str(bridge.request("get_screen").get("screenClass", "")) + and "DownloadScreen" + not in str(bridge.request("get_screen").get("screenClass", "")) + and "RestartScreen" + not in str(bridge.request("get_screen").get("screenClass", "")) + and bridge.request("get_screen").get("screenClass") is None + or None + ), + to, + f"{ctx['target'].id}: Player did not join in-game within {to}s", + ) diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile new file mode 100644 index 000000000..613244c93 --- /dev/null +++ b/autotester/docker/client/Dockerfile @@ -0,0 +1,41 @@ +FROM ubuntu:26.04 + +ARG HEADLESSMC_VERSION=2.9.0 + +ENV DEBIAN_FRONTEND=noninteractive +ENV HMC_HOME=/work/hmc-cache + +# Install Java 17, 21, 25 from repos +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + unzip \ + bash \ + procps \ + libatomic1 \ + libx11-6 \ + libxext6 \ + libxrender1 \ + libxtst6 \ + libxi6 \ + libxrandr2 \ + libxxf86vm1 \ + libgl1 \ + libglx-mesa0 \ + libasound2t64 \ + openjdk-17-jre-headless \ + openjdk-21-jre-headless \ + openjdk-25-jre-headless \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache \ + && curl -fsSL "https://github.com/headlesshq/headlessmc/releases/download/${HEADLESSMC_VERSION}/headlessmc-launcher-linux-x64" \ + -o /usr/local/bin/hmc \ + && chmod +x /usr/local/bin/hmc + +COPY run-headlessmc-client /opt/automodpack/run-headlessmc-client +RUN chmod +x /opt/automodpack/run-headlessmc-client + +WORKDIR /work +ENTRYPOINT [] diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client new file mode 100644 index 000000000..421414754 --- /dev/null +++ b/autotester/docker/client/run-headlessmc-client @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +loader="${1:?loader required}" +minecraft="${2:?minecraft version required}" +host="${3:?server host required}" +port="${4:?server port required}" +java_version="${5:?java version required}" +loader_version="${6:-}" + +game_dir="${AM_AUTOTEST_GAME_DIR:-/work/game}" +hmc_dir="${AM_AUTOTEST_HMC_DIR:-/work/hmc-cache}" +bridge_token="${AM_AUTOTEST_BRIDGE_TOKEN:?bridge token required}" + +mkdir -p "$game_dir" "$hmc_dir" +mkdir -p "$hmc_dir/HeadlessMC" + +# Select pre-installed Java - no HMC auto-download +case "$java_version" in + 17) + export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" + ;; + 21) + export JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64" + ;; + 25) + export JAVA_HOME="/usr/lib/jvm/java-25-temurin" + ;; + *) + echo "ERROR: unsupported Java version: $java_version" >&2 + exit 1 + ;; +esac +export PATH="$JAVA_HOME/bin:$PATH" + +jvmargs="-Dautomodpack.autotest=true -Dautomodpack.autotest.gamedir=${game_dir} -Dautomodpack.autotest.token=${bridge_token} -Dcom.mojang.text2speech=false" +gameargs="--width 854 --height 480" + +cat > "$hmc_dir/HeadlessMC/config.properties" </dev/null || true +hmc --command "config -refresh" 2>/dev/null || true + +exec hmc \ + --command "launch ${launch_target} -lwjgl -offline --jvm \"${jvmargs}\" ${gameargs}" diff --git a/autotester/pyproject.toml b/autotester/pyproject.toml new file mode 100644 index 000000000..84eb85ddd --- /dev/null +++ b/autotester/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "automodpack-autotest" +version = "0.1.0" +description = "Docker-first in-game integration tests for AutoModpack" +requires-python = ">=3.11" +dependencies = [ + "PyYAML>=6.0.1", +] + +[project.scripts] +autotester = "automodpack_autotester.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["automodpack_autotester*"] diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml new file mode 100644 index 000000000..c951d7a96 --- /dev/null +++ b/autotester/scenarios/download-only.yaml @@ -0,0 +1,49 @@ +# ── scenario: download-only ───────────────────────────────────────────── +# Minimal: launch_server → wait_server → read_fingerprint → launch_client +# → connect → fingerprint → download → verify → quit. +# Skips restart and rejoin — faster feedback when debugging downloads. +# +# See sync.yaml for detailed documentation on serverFiles, server engine, +# server caching (Docker volumes), and client caching (HMC cache seed). + +id: download-only +description: | + Launch server/client → accept fingerprint → sync modpack → verify → quit. + Skips restart/rejoin for faster iteration. + +flow: + - launch_server + - launch_client + - read_fingerprint + - wait_server + - wait_bridge + - click_continue + - connect + - wait_fingerprint + - accept_fingerprint + - wait_danger + - click_confirm + - wait_download + - verify_files + - quit + +topology: + server: + memory: 2G + env: + ENABLE_ROLLING_LOGS: "false" + modrinth: + projects: + - ferrite-core? + dependencies: true + +serverFiles: + modpackName: amp-autotest + marker: config/amp-autotest-marker.json + files: + - path: config/amp-autotest-alpha.txt + content: "amp-autotest-alpha\n" + - path: config/amp-autotest-beta.json + content: '{"id":"beta","value":42}' + - path: config/amp-autotest-gamma.cfg + content: "alpha=true\nbeta=false\n" diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml new file mode 100644 index 000000000..89eb98767 --- /dev/null +++ b/autotester/scenarios/sync.yaml @@ -0,0 +1,121 @@ +# ── scenario: sync ────────────────────────────────────────────────────── +# Full end-to-end: launches server + client, connects, verifies fingerprint, +# downloads the modpack, verifies files, restarts, and rejoins. +# +# CLI: autotester run --scenario sync +# New scenario: copy this file, change `id` and `flow:` list. +# +# ── Phases ────────────────────────────────────────────────────────────── +# Edit the `flow:` list below. Each entry is a phase name registered in +# runner.py → PHASES dict. Add/remove/reorder freely — no Python changes. +# +# Known phases: +# launch_server Start the server container (itzg/minecraft-server). +# wait_server Wait for "Done (" in server logs. +# read_fingerprint Extract TLS certificate fingerprint from server logs. +# launch_client Start/restart the client container (HeadlessMC). +# wait_bridge Poll bridge-state.json, ping the bridge TCP handler. +# click_continue Click "Continue" on Forge/NeoForge migration screens. +# connect Bridge: connect to server at {srv_name}:25565. +# wait_fingerprint Wait until the client opens FingerprintVerificationScreen. +# accept_fingerprint Type fingerprint into EditBox, click Verify. +# skip_fingerprint Skip verification (versions without EditBox). +# wait_danger Poll until DangerScreen appears. +# click_confirm Click the confirm button on DangerScreen. +# wait_download Poll for the marker file in the synced modpack. +# verify_files Check all serverFiles exist in the synced modpack. +# verify_mods Check expected mod glob patterns match installed jars. +# click_restart If RestartScreen → click Restart; wait for exit. +# quit Bridge: quit game → wait for client exit. +# wait_join Poll null screenClass (player in-game). +# +# ── serverFiles (replaces old "assertions") ───────────────────────────── +# Describes files the SERVER hosts in its modpack; the client downloads +# and syncs them. Everything is under the server's host-modpack/main/ dir. +# +# modpackName Namespace for the modpack (both server and client). +# Server: host-modpack/main/ +# Client: modpacks/{modpackName}/ +# +# marker Relative path inside the modpack that signals +# "sync completed". Written to host-modpack/main/ before +# server start. Verified by `wait_download` on the client. +# The file is NOT checked into the repo — it is generated +# by _prepare_server() before each test run. +# +# files List of {path, content} — files written to the server's +# host-modpack/main/ BEFORE server launch. After sync, +# `verify_files` checks each exists under the client's +# modpacks/{modpackName}/ directory. +# +# expectedMods Glob patterns for .jar files expected in the synced +# mods/ directory. Add `verify_mods` to flow to enable. +# Not used by default (add when testing mod filtering). +# +# ── Topology ──────────────────────────────────────────────────────────── +# topology.server.type overrides the engine mapping from settings.yaml: +# fabric → FABRIC, forge → FORGE, neoforge → NEOFORGE +# If unset, settings.yaml → serverTypes maps loader→type. +# The value becomes the TYPE env var for itzg/minecraft-server. +# +# topology.server.modrinth defines extra mods via Modrinth API. +# topology.server.env merges with (and overrides) settings.yaml → server.env. +# topology.server.memory sets container memory (default 2G). +# +# ── Caching ───────────────────────────────────────────────────────────── +# Server: Docker volume amp-server-cache-{target.id} persists JARs. +# Set serverCache.clean: true in settings.yaml to purge before each run. +# Client: HMC cache at ~/.hmc-cache/ seeded via cp --reflink before each +# run to avoid re-downloading Minecraft jars every test. +# +# ── Timeouts ──────────────────────────────────────────────────────────── +# Per-scenario overrides for settings.yaml timeouts. Set under timeouts: {}. +# Keys: serverStartSeconds, clientStartSeconds, downloadFileSeconds, rejoinSeconds. + +id: sync +description: | + Launch server/client → accept fingerprint → sync modpack → verify → restart → rejoin → in-game check. + +flow: + - launch_server # start the server container + - launch_client # start client immediately — both boot in parallel + - read_fingerprint # extract TLS fingerprint from server logs + - wait_server # wait for "Done (" — runs concurrent with client boot + - wait_bridge # poll bridge-state.json, ping bridge + - click_continue # click Continue on welcome screens + - connect # bridge.connect(host, port) + - wait_fingerprint # wait for FingerprintVerificationScreen + - accept_fingerprint # type fingerprint in EditBox → click Verify + - wait_danger # poll until DangerScreen appears + - click_confirm # click last active Button (Confirm) + - wait_download # poll for marker file in synced modpack + - verify_files # check all serverFiles exist + - click_restart # if RestartScreen → click Restart; wait for exit + - quit # bridge.quit() → wait for client exit + - launch_client # fresh client container (rejoin) + - wait_bridge # wait for bridge on new client + - click_continue # click Continue on welcome screens + - connect # connect to server + - wait_join # poll null screenClass (in-game) + - quit # quit + +topology: + server: + memory: 2G + env: + ENABLE_ROLLING_LOGS: "false" + modrinth: + projects: + - ferrite-core? + dependencies: true + +serverFiles: + modpackName: amp-autotest + marker: config/amp-autotest-marker.json + files: + - path: config/amp-autotest-alpha.txt + content: "amp-autotest-alpha\n" + - path: config/amp-autotest-beta.json + content: '{"id":"beta","value":42}' + - path: config/amp-autotest-gamma.cfg + content: "alpha=true\nbeta=false\n" diff --git a/autotester/settings.yaml b/autotester/settings.yaml new file mode 100644 index 000000000..2351f4339 --- /dev/null +++ b/autotester/settings.yaml @@ -0,0 +1,80 @@ +paths: + artifactDir: merged + outDir: autotester/out + +images: + server: itzg/minecraft-server + proxy: itzg/mc-proxy + client: automodpack-autotest-client:local + serverTagTemplate: "java{java}" + +run: + target: all + scenario: sync + jobs: 4 + retryMax: 0 + +server: + memory: 2G + nameTemplate: "amp-{target_id}" + env: + EULA: "TRUE" + ONLINE_MODE: "FALSE" + ENABLE_AUTOPAUSE: "FALSE" + MOTD: "amp-autotest" + DIFFICULTY: "peaceful" + SPAWN_ANIMALS: "false" + SPAWN_MONSTERS: "false" + SPAWN_NPCS: "false" + GENERATE_STRUCTURES: "false" + LEVEL_TYPE: "void" + SPAWN_RADIUS: "0" + +serverTypes: + fabric: FABRIC + forge: FORGE + neoforge: NEOFORGE + +headlessmc: + version: "2.9.0" + +timeouts: + serverStartSeconds: 180 + clientStartSeconds: 180 + downloadFileSeconds: 180 + rejoinSeconds: 90 + +serverCache: + enabled: true + volumePrefix: "amp-server-cache" + clean: false + +automodpack: + config: + DO_NOT_CHANGE_IT: 2 + modpackHost: true + generateModpackOnStart: true + syncedFiles: + - "/mods/*.jar" + - "/kubejs/**" + - "!/kubejs/server_scripts/**" + - "/emotes/*" + allowEditsInFiles: + - "/config/**" + forceCopyFilesToStandardLocation: [] + nonModpackFilesToDelete: {} + autoExcludeServerSideMods: true + autoExcludeUnnecessaryFiles: true + requireAutoModpackOnClient: true + nagUnModdedClients: false + bindAddress: "" + bindPort: -1 + addressToSend: "" + portToSend: -1 + disableInternalTLS: false + requireMagicPackets: false + updateIpsOnEveryStart: false + bandwidthLimit: 0 + validateSecrets: true + secretLifetime: 336 + selfUpdater: false diff --git a/autotester/targets.yaml b/autotester/targets.yaml new file mode 100644 index 000000000..557be7f3a --- /dev/null +++ b/autotester/targets.yaml @@ -0,0 +1,27 @@ +defaults: + artifactPattern: "automodpack-mc{minecraft}-{loader}-*.jar" + fabricLoader: "0.17.3" + +targets: + - { id: "26.1-fabric", minecraft: "26.1", loader: "fabric", java: 25, fabricLoader: "0.18.4" } + - { id: "26.1-neoforge", minecraft: "26.1", loader: "neoforge", java: 25, neoforgeVersion: "26.1.0.1-beta" } + - { id: "1.21.11-fabric", minecraft: "1.21.11", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.11-neoforge", minecraft: "1.21.11", loader: "neoforge", java: 21, neoforgeVersion: "21.11.37-beta" } + - { id: "1.21.10-fabric", minecraft: "1.21.10", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.10-neoforge", minecraft: "1.21.10", loader: "neoforge", java: 21, neoforgeVersion: "21.10.64" } + - { id: "1.21.8-fabric", minecraft: "1.21.8", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.8-neoforge", minecraft: "1.21.8", loader: "neoforge", java: 21, neoforgeVersion: "21.8.50" } + - { id: "1.21.5-fabric", minecraft: "1.21.5", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.5-neoforge", minecraft: "1.21.5", loader: "neoforge", java: 21, neoforgeVersion: "21.5.97" } + - { id: "1.21.4-fabric", minecraft: "1.21.4", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.4-neoforge", minecraft: "1.21.4", loader: "neoforge", java: 21, neoforgeVersion: "21.4.157" } + - { id: "1.21.1-fabric", minecraft: "1.21.1", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.1-neoforge", minecraft: "1.21.1", loader: "neoforge", java: 21, neoforgeVersion: "21.1.230" } + - { id: "1.20.4-fabric", minecraft: "1.20.4", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.20.4-neoforge", minecraft: "1.20.4", loader: "neoforge", java: 17, neoforgeVersion: "20.4.248" } + - { id: "1.20.1-fabric", minecraft: "1.20.1", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.20.1-forge", minecraft: "1.20.1", loader: "forge", java: 17, forgeVersion: "47.3.0" } + - { id: "1.19.2-fabric", minecraft: "1.19.2", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.19.2-forge", minecraft: "1.19.2", loader: "forge", java: 17, forgeVersion: "43.3.0" } + - { id: "1.18.2-fabric", minecraft: "1.18.2", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.18.2-forge", minecraft: "1.18.2", loader: "forge", java: 17, forgeVersion: "40.2.10" } diff --git a/autotester/uv.lock b/autotester/uv.lock new file mode 100644 index 000000000..5bc665836 --- /dev/null +++ b/autotester/uv.lock @@ -0,0 +1,69 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "automodpack-autotest" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6.0.1" }] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] diff --git a/build.fabric.gradle.kts b/build.fabric.gradle.kts index 3e458a666..deee58a10 100644 --- a/build.fabric.gradle.kts +++ b/build.fabric.gradle.kts @@ -66,13 +66,26 @@ java { } else { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 - toolchain.languageVersion.set(JavaLanguageVersion.of(17)) } withSourcesJar() } +sourceSets.main { + java.setSrcDirs(listOf(layout.buildDirectory.dir("generated/stonecutter/main/java"))) +} + tasks { + named("sourcesJar") { + dependsOn("stonecutterGenerate") + } + named("compileJava") { + dependsOn("stonecutterGenerate") + } + named("compileKotlin") { + dependsOn("stonecutterGenerate") + } processResources { + dependsOn("stonecutterGenerate") exclude("**/neoforge.mods.toml", "**/mods.toml", "**/accesstransformer*.cfg") if (fabric.isUnobf) { exclude("**/automodpack.accesswidener") diff --git a/build.forge.gradle.kts b/build.forge.gradle.kts index 6fd228ea9..4124762b9 100644 --- a/build.forge.gradle.kts +++ b/build.forge.gradle.kts @@ -93,7 +93,6 @@ java { } else { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 - toolchain.languageVersion.set(JavaLanguageVersion.of(17)) } withSourcesJar() } diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts index 6cd3a2d20..e5fcda3b1 100644 --- a/build.neoforge.gradle.kts +++ b/build.neoforge.gradle.kts @@ -25,12 +25,17 @@ dependencies { implementation(project(":core")) { isTransitive = false } implementation(project(":loader-core")) { isTransitive = false } - implementation("org.sinytra.forgified-fabric-api:forgified-fabric-api:0.115.6+2.1.4+1.21.1") + if (sc.current.parsed >= "1.21") { + implementation("org.sinytra.forgified-fabric-api:forgified-fabric-api:0.115.6+2.1.4+1.21.1") + } } tasks { processResources { - exclude("**/fabric.mod.json", "**/automodpack*.accesswidener", "**/mods.toml") + exclude("**/fabric.mod.json", "**/automodpack*.accesswidener") + if (sc.current.parsed >= "1.21") { + exclude("**/mods.toml") + } if (sc.current.parsed >= "1.21.9") { exclude("**/pack.mcmeta") rename("new-pack.mcmeta", "pack.mcmeta") @@ -49,10 +54,18 @@ java { sourceCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_25 toolchain.languageVersion.set(JavaLanguageVersion.of(25)) - } else { + } else if (sc.current.parsed >= "1.21") { withSourcesJar() sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } else if (sc.current.parsed >= "1.20.5") { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } else { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } } diff --git a/buildSrc/src/main/kotlin/MergeJarTask.kt b/buildSrc/src/main/kotlin/MergeJarTask.kt index 68b913f37..8d5338ba6 100644 --- a/buildSrc/src/main/kotlin/MergeJarTask.kt +++ b/buildSrc/src/main/kotlin/MergeJarTask.kt @@ -86,4 +86,4 @@ abstract class MergeJarTask : DefaultTask() { outputJar.get().asFile.writeText(finalJar.absolutePath) println("Merged: ${jarToMerge.name} and ${zstdFile.name} from: ${loaderFile.name} into: ${finalJar.name} Took: ${System.currentTimeMillis() - time}ms") } -} \ No newline at end of file +} diff --git a/buildSrc/src/main/kotlin/ModuleUtils.kt b/buildSrc/src/main/kotlin/ModuleUtils.kt index 7702bb3c3..8f5784a28 100644 --- a/buildSrc/src/main/kotlin/ModuleUtils.kt +++ b/buildSrc/src/main/kotlin/ModuleUtils.kt @@ -10,9 +10,9 @@ fun getLoaderModuleName(name: String): String { return when { name.contains("fabric") -> "fabric-core" name.contains("neoforge") -> when (mcVersion) { - "1.20.6", "1.20.4", "1.20.1", "1.19.4", "1.19.2", "1.18.2" -> "neoforge-fml2" - "1.21.8", "1.21.5", "1.21.4", "1.21.3", "1.21.1" -> "neoforge-fml4" - "1.21.11", "1.21.10", "26.1", "26.2" -> "neoforge-fml10" + "1.20.4", "1.20.1", "1.19.2", "1.18.2" -> "neoforge-fml2" + "1.21.8", "1.21.5", "1.21.4", "1.21.1" -> "neoforge-fml4" + "1.21.11", "1.21.10", "26.1" -> "neoforge-fml10" else -> error("Unknown neoforge loader module for Minecraft version: $mcVersion") } name.contains("forge") -> if (mcVersion == "1.18.2") "forge-fml40" else "forge-fml47" diff --git a/buildSrc/src/main/kotlin/automodpack.common.gradle.kts b/buildSrc/src/main/kotlin/automodpack.common.gradle.kts index 416238a3f..18fa49a81 100644 --- a/buildSrc/src/main/kotlin/automodpack.common.gradle.kts +++ b/buildSrc/src/main/kotlin/automodpack.common.gradle.kts @@ -47,9 +47,14 @@ val mergeJarTask = tasks.register("mergeJar") { this.outputJar.set(layout.buildDirectory.file("merged-jar-path.txt")) val filesToHash = mutableListOf() + tasks.findByName("jar")?.let { projectJar -> + dependsOn(projectJar) + filesToHash.add(projectJar) + } for (module in getAllDependentLoaderModules(project.name)) { - val modLoaderJar = rootProject.project(module).tasks.named("jar") - filesToHash.add(modLoaderJar) + rootProject.project(module).tasks.findByName("jar")?.let { modLoaderJar -> + filesToHash.add(modLoaderJar) + } } // Compute the actual hash of the content of all input jars. @@ -113,4 +118,4 @@ tasks.register("optimizeMergedJar") { println("Optimized ${jarFile.name} - Took: ${System.currentTimeMillis() - time}ms") } } -} \ No newline at end of file +} diff --git a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java index c6ad39ccb..3b14b48f5 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java +++ b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java @@ -32,13 +32,21 @@ public class DownloadClient implements AutoCloseable { private final List connections = new ArrayList<>(); - private InetSocketAddress address = null; + + private record InitialConnectionResult(PreValidationConnection connection, SSLContext sslContext) {} /** - * Transports the connection and the specific SSLContext used to create it. - * Required because the SSLContext may change dynamically during trust recovery. + * Holds the outcome of a single probe attempt, along with the KeyStore for later mutation. */ - private record InitialConnectionResult(PreValidationConnection connection, SSLContext sslContext) {} + private record ProbeResult(InitialConnectionResult success, X509Certificate untrustedCert, IOException error, KeyStore keyStore) {} + + /** + * Package-private constructor for the async {@link #createAsync} path. + * Accepts a pre-hydrated connection pool. + */ + DownloadClient(List connections) { + this.connections.addAll(connections); + } /** * Initializes the client by establishing a single "probe" connection to validate/recover SSL trust, @@ -49,46 +57,106 @@ public DownloadClient(Jsons.ModpackAddresses modpackAddresses, byte[] secretByte KeyStore keyStore = loadDefaultKeyStore(); - // Establish probe to handle potential SSL handshake errors (e.g., self-signed certs) sequentially before pooling. InitialConnectionResult probe = establishProbeConnection(modpackAddresses, keyStore, trustedByUserCallback); + connections.addAll(hydratePool(probe, secretBytes, poolSize, modpackAddresses)); + + LOGGER.info("Download client initialized with {} connections to {}", connections.size(), modpackAddresses.hostAddress); + } + + /** + * Async factory. If the certificate needs user approval, no thread blocks: the returned future + * completes when the trust callback's future completes (via UI callbacks on the render thread). + */ + public static CompletableFuture createAsync( + Jsons.ModpackAddresses addresses, + byte[] secretBytes, + int poolSize, + Function> trustCallback) { - if (probe.connection.getSocket() != null && !probe.connection.getSocket().isClosed()) { + return CompletableFuture.supplyAsync(() -> { + KeyStore keyStore = loadDefaultKeyStore(); + AtomicReference capturedChain = new AtomicReference<>(); + SSLContext context = createSSLContext(keyStore, capturedChain::set); + + try { + PreValidationConnection probe = getPreValidationConnection(addresses, context); + return new ProbeResult(new InitialConnectionResult(probe, context), null, null, keyStore); + } catch (IOException e) { + X509Certificate[] chain = capturedChain.get(); + X509Certificate untrusted = (chain != null && chain.length > 0) ? chain[0] : null; + return new ProbeResult(null, untrusted, e, keyStore); + } + }).thenCompose(pr -> { + if (pr.success != null) { + try { + return CompletableFuture.completedFuture( + new DownloadClient(hydratePool(pr.success, secretBytes, poolSize, addresses))); + } catch (IOException e) { + return CompletableFuture.failedFuture(e); + } + } + if (pr.untrustedCert == null || trustCallback == null) { + return CompletableFuture.failedFuture(pr.error); + } + + return trustCallback.apply(pr.untrustedCert) + .thenComposeAsync(trusted -> { + if (!trusted) { + return CompletableFuture.failedFuture(new IOException("User rejected certificate")); + } + return CompletableFuture.supplyAsync(() -> { + try { + pr.keyStore.setCertificateEntry(addresses.hostAddress.getHostString(), pr.untrustedCert); + SSLContext trustedCtx = createSSLContext(pr.keyStore, null); + PreValidationConnection retry = getPreValidationConnection(addresses, trustedCtx); + return new DownloadClient(hydratePool( + new InitialConnectionResult(retry, trustedCtx), + secretBytes, poolSize, addresses)); + } catch (Exception e) { + throw new CompletionException(new IOException("Failed to reconnect after trust", e)); + } + }); + }, ForkJoinPool.commonPool()) + .orTimeout(120, TimeUnit.SECONDS) + .exceptionally(e -> { + throw new CompletionException(new IOException("Certificate not trusted", e)); + }); + }); + } + + /** + * Hydrates the connection pool from a successful probe + SSL context. + * Shared by both sync and async construction paths. + */ + private static List hydratePool(InitialConnectionResult probe, byte[] secretBytes, int poolSize, Jsons.ModpackAddresses addresses) throws IOException { + List conns = new ArrayList<>(); + if (probe.connection().getSocket() != null && !probe.connection().getSocket().isClosed()) { if (secretBytes == null) { probe.connection().getSocket().close(); + return conns; } else { - connections.add(new Connection(probe.connection, secretBytes)); + conns.add(new Connection(probe.connection(), secretBytes)); } } + if (secretBytes == null) return conns; - if (secretBytes == null) { - return; - } - - int remainingNeeded = poolSize - connections.size(); - if (remainingNeeded < 1) { - return; - } + int remainingNeeded = poolSize - conns.size(); + if (remainingNeeded < 1) return conns; - // Parallel pool hydration using the session-aware SSLContext from the probe. List newConnections = IntStream.range(0, remainingNeeded) .parallel() .mapToObj(i -> { try { - return new Connection(getPreValidationConnection(modpackAddresses, probe.sslContext), secretBytes); + return new Connection(getPreValidationConnection(addresses, probe.sslContext()), secretBytes); } catch (IOException e) { throw new CompletionException(e); } }) .toList(); - - connections.addAll(newConnections); - LOGGER.info("Download client initialized with {} connections to {}", connections.size(), modpackAddresses.hostAddress.getHostString()); + conns.addAll(newConnections); + return conns; } - /** - * Attempts a connection with a capturing trust manager. If the handshake fails due to an - * untrusted certificate, the chain is captured, presented to the user callback, and the connection is retried. - */ private InitialConnectionResult establishProbeConnection(Jsons.ModpackAddresses addresses, KeyStore keyStore, Function trustCallback) throws IOException { AtomicReference capturedChain = new AtomicReference<>(); SSLContext context = createSSLContext(keyStore, capturedChain::set); @@ -96,7 +164,7 @@ private InitialConnectionResult establishProbeConnection(Jsons.ModpackAddresses try { PreValidationConnection conn = getPreValidationConnection(addresses, context); return new InitialConnectionResult(conn, context); - } catch (IOException e) { // Inavlid/Selfsigned certificate, prompt user for trust. + } catch (IOException e) { return recoverProbeConnection(e, addresses, keyStore, trustCallback, capturedChain.get()); } } @@ -114,7 +182,6 @@ private InitialConnectionResult recoverProbeConnection(IOException originalError try { keyStore.setCertificateEntry(addresses.hostAddress.getHostString(), chain[0]); - // Re-initialize context with the updated KeyStore containing the user-trusted cert. SSLContext trustedContext = createSSLContext(keyStore, null); PreValidationConnection retryConn = getPreValidationConnection(addresses, trustedContext); @@ -125,7 +192,7 @@ private InitialConnectionResult recoverProbeConnection(IOException originalError } } - private KeyStore loadDefaultKeyStore() { + private static KeyStore loadDefaultKeyStore() { try { KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null); @@ -135,22 +202,16 @@ private KeyStore loadDefaultKeyStore() { } } - private PreValidationConnection getPreValidationConnection(Jsons.ModpackAddresses modpackAddresses, SSLContext sharedContext) throws IOException { + private static PreValidationConnection getPreValidationConnection(Jsons.ModpackAddresses modpackAddresses, SSLContext sharedContext) throws IOException { String hostName = modpackAddresses.hostAddress.getHostString(); - if (address == null) { - address = new InetSocketAddress(hostName, modpackAddresses.hostAddress.getPort()); - if (address.isUnresolved()) { - throw new IOException("Failed to resolve host address: " + hostName); - } + InetSocketAddress address = new InetSocketAddress(hostName, modpackAddresses.hostAddress.getPort()); + if (address.isUnresolved()) { + throw new IOException("Failed to resolve host address: " + hostName); } return new PreValidationConnection(address, modpackAddresses, sharedContext); } - /** - * Configures SSL context with TLSv1.3 and a custom TrustManager. - * @param onValidating Optional callback to capture certificate chains during handshake failures. - */ - private SSLContext createSSLContext(KeyStore trustedCertificates, Consumer onValidating) { + private static SSLContext createSSLContext(KeyStore trustedCertificates, Consumer onValidating) { try { SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); X509ExtendedTrustManager trustManager = new CustomizableTrustManager(trustedCertificates, onValidating); @@ -177,10 +238,6 @@ public static DownloadClient tryCreate(Jsons.ModpackAddresses modpackAddresses, } } - /** - * Recursively searches for an idle connection in the pool. - * Marks the found connection as busy to prevent race conditions. - */ private synchronized Connection getFreeConnection() { Iterator iterator = connections.iterator(); while (iterator.hasNext()) { @@ -214,10 +271,6 @@ public void close() { } } -/** - * Handles the initial TCP connection and protocol-specific handshakes (Magic) - * prior to or during the SSL upgrade. - */ class PreValidationConnection { private final SSLSocket socket; @@ -227,7 +280,6 @@ public PreValidationConnection(InetSocketAddress resolvedHostAddress, Jsons.Modp plainSocket.connect(resolvedHostAddress, 10000); plainSocket.setSoTimeout(10000); - // Perform custom "Magic" handshake over plain text if required by config. if (modpackAddresses.requiresMagic) { try { DataOutputStream plainOut = new DataOutputStream(new BufferedOutputStream(plainSocket.getOutputStream())); @@ -250,7 +302,6 @@ public PreValidationConnection(InetSocketAddress resolvedHostAddress, Jsons.Modp } } - // Layer SSL over the existing socket. SSLSocketFactory factory = sslContext.getSocketFactory(); SSLSocket sslSocket = (SSLSocket) factory.createSocket(plainSocket, resolvedHostAddress.getHostString(), resolvedHostAddress.getPort(), true); @@ -276,10 +327,6 @@ protected SSLSocket getSocket() { } } -/** - * Manages an active, authenticated session. Handles protocol negotiation, - * framing, compression, and async I/O. - */ class Connection implements AutoCloseable { private byte protocolVersion = LATEST_SUPPORTED_PROTOCOL_VERSION; @@ -292,8 +339,6 @@ class Connection implements AutoCloseable { private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final AtomicBoolean busy = new AtomicBoolean(false); - // Reuse this buffer for reading from socket to avoid allocation per frame. - // Size = Default Chunk + Header overhead (approx) + Safety margin private final byte[] networkInputBuffer = new byte[MAX_CHUNK_SIZE + 8192]; public Connection(PreValidationConnection preValidationConnection, byte[] secretBytes) throws IOException { @@ -306,7 +351,6 @@ public Connection(PreValidationConnection preValidationConnection, byte[] secret this.in = new DataInputStream(new BufferedInputStream(this.socket.getInputStream())); this.out = new DataOutputStream(new BufferedOutputStream(this.socket.getOutputStream())); - // Negotiate connection parameters sequentially. try { if (!PlatformUtils.canUseZstd()) { this.compressionType = COMPRESSION_GZIP; @@ -389,9 +433,6 @@ public CompletableFuture sendRefreshRequest(byte[][] fileHashes, Path dest }, executor); } - /** - * Cleans up input stream and releases the busy flag upon completion. - */ private void finalBlock(Exception exception) { try { int available; @@ -405,9 +446,6 @@ private void finalBlock(Exception exception) { } } - /** - * Segments payload into chunks, compresses them, and sends with protocol framing. - */ private void writeProtocolMessage(byte[] payload) throws IOException { CompressionCodec codec = getCompressionCodec(); int offset = 0; @@ -427,14 +465,11 @@ private void writeProtocolMessage(byte[] payload) throws IOException { out.flush(); } - /** - * Reads a framing header (Compressed Len + Original Len) and returns decompressed data. - */ private byte[] readProtocolMessageFrame() throws IOException { int compressedLength = in.readInt(); int originalLength = in.readInt(); - int maxAllowedSize = this.chunkSize + 8192; // Allow overhead buffer + int maxAllowedSize = this.chunkSize + 8192; if (compressedLength < 0 || compressedLength > maxAllowedSize) { throw new IOException("Frame compressed length (" + compressedLength + ") exceeds limit (" + maxAllowedSize + ")"); @@ -453,9 +488,6 @@ private byte[] readProtocolMessageFrame() throws IOException { return getCompressionCodec().decompress(networkInputBuffer, 0, compressedLength, originalLength); } - /** - * Processes the server response stream. Expects Header -> Data Frames -> EOT. - */ private Path readFileResponse(Path destination, IntConsumer chunkCallback) throws IOException { byte[] headerData = readProtocolMessageFrame(); ByteBuffer headerWrap = ByteBuffer.wrap(headerData); @@ -566,4 +598,4 @@ public void close() { try { socket.close(); } catch (Exception ignored) {} executor.shutdownNow(); } -} \ No newline at end of file +} diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java index 5c77d010f..475747723 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java @@ -5,20 +5,22 @@ import pl.skidam.automodpack_core.config.ConfigTools; import pl.skidam.automodpack_core.config.ConfigUtils; import pl.skidam.automodpack_core.config.Jsons; +import pl.skidam.automodpack_core.loader.LoaderManagerService; import pl.skidam.automodpack_core.utils.*; import pl.skidam.automodpack_loader_core.client.ModpackUpdater; import pl.skidam.automodpack_loader_core.client.ModpackUtils; import pl.skidam.automodpack_loader_core.loader.LoaderManager; -import pl.skidam.automodpack_core.loader.LoaderManagerService; import pl.skidam.automodpack_loader_core.mods.ModpackLoader; +import pl.skidam.automodpack_loader_core.screen.ScreenManager; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; -import java.nio.file.*; +import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; -import java.util.*; +import java.util.HashMap; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -32,6 +34,7 @@ public Preload() { LOGGER.info("Prelaunching AutoModpack..."); initializeConstants(); loadConfigs(); + bootstrapAutotestClientHooks(); updateAll(); LOGGER.info("AutoModpack prelaunched! took " + (System.currentTimeMillis() - start) + "ms"); } catch (Exception e) { @@ -83,8 +86,16 @@ private void updateAll() { // Delete dummy files LegacyClientCacheUtils.deleteDummyFiles(); - if (clientConfig.updateSelectedModpackOnLaunch) { - new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir).processModpackUpdate(null); + var modpackUpdaterInstance = new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir); + + if (clientConfig.updateSelectedModpackOnLaunch) { // Check updates and load the modpack + modpackUpdaterInstance.processModpackUpdate(null); + } else { // Otherwise just load the modpack + try { + modpackUpdaterInstance.CheckAndLoadModpack(); + } catch (Exception e) { + LOGGER.error("Failed to check and load modpack, trying to update it", e); + } } } } @@ -245,4 +256,25 @@ private void loadConfigs() { LOGGER.info("Loaded config! took {}ms", System.currentTimeMillis() - startTime); } + + private void bootstrapAutotestClientHooks() { + if (!Boolean.getBoolean("automodpack.autotest")) { + return; + } + if (LOADER_MANAGER.getEnvironmentType() != LoaderManagerService.EnvironmentType.CLIENT) { + return; + } + try { + Class screenImplClass = Class.forName("pl.skidam.automodpack.client.ScreenImpl"); + ScreenManager.INSTANCE = (pl.skidam.automodpack_loader_core.screen.ScreenService) screenImplClass.getDeclaredConstructor().newInstance(); + } catch (Throwable e) { + LOGGER.warn("Failed to bootstrap AutoModpack ScreenImpl during prelaunch", e); + } + try { + Class bridgeClass = Class.forName("pl.skidam.automodpack.client.autotest.AutoTestBridge"); + bridgeClass.getMethod("startIfEnabled").invoke(null); + } catch (Throwable e) { + LOGGER.warn("Failed to start AutoModpack autotest bridge during prelaunch", e); + } + } } diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java index 3a879477c..b7609324b 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java @@ -3,21 +3,27 @@ import org.jetbrains.annotations.Nullable; import pl.skidam.automodpack_core.auth.Secrets; import pl.skidam.automodpack_core.auth.SecretsStore; -import pl.skidam.automodpack_core.config.Jsons; import pl.skidam.automodpack_core.config.ConfigTools; +import pl.skidam.automodpack_core.config.Jsons; import pl.skidam.automodpack_core.protocol.DownloadClient; -import pl.skidam.automodpack_core.utils.*; +import pl.skidam.automodpack_core.utils.FileInspection; +import pl.skidam.automodpack_core.utils.LegacyClientCacheUtils; +import pl.skidam.automodpack_core.utils.SmartFileUtils; +import pl.skidam.automodpack_core.utils.WorkaroundUtil; import pl.skidam.automodpack_core.utils.cache.FileMetadataCache; import pl.skidam.automodpack_core.utils.cache.ModFileCache; import pl.skidam.automodpack_core.utils.launchers.LauncherVersionSwapper; import pl.skidam.automodpack_loader_core.ReLauncher; import pl.skidam.automodpack_loader_core.screen.ScreenManager; -import pl.skidam.automodpack_loader_core.utils.*; +import pl.skidam.automodpack_loader_core.utils.DownloadManager; +import pl.skidam.automodpack_loader_core.utils.FetchManager; +import pl.skidam.automodpack_loader_core.utils.UpdateType; import java.io.IOException; import java.net.ConnectException; import java.net.SocketTimeoutException; -import java.nio.file.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -112,6 +118,12 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { } } + public void CheckAndLoadModpack() throws Exception { + try (var cache = FileMetadataCache.open(hashCacheDBFile)) { + CheckAndLoadModpack(cache); + } + } + private void CheckAndLoadModpack(FileMetadataCache cache) throws Exception { if (!Files.exists(modpackDir)) return; @@ -170,6 +182,7 @@ private void CheckAndLoadModpack(FileMetadataCache cache) throws Exception { public void startUpdate(Set filesToUpdate) { if (modpackSecret == null) { LOGGER.error("Cannot update modpack, secret is null"); + new ScreenManager().error("automodpack.error.critical", "Secret is null - cannot update", "automodpack.error.logs"); return; } diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index 73151268c..4a6625172 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -23,7 +23,9 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.stream.Collectors; @@ -729,10 +731,22 @@ public static Function userValidationCallback(InetSock private static Boolean askUserAboutCertificate(InetSocketAddress address, String fingerprint) { LOGGER.info("Asking user for {}", address.getHostString()); - Optional screen = new ScreenManager().getScreen(); - if (screen.isEmpty()) { - LOGGER.warn("No screen available, cannot ask user"); - return false; + + Object parent = null; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (parent == null) { + parent = new ScreenManager().getScreen().orElse(null); + if (parent == null) { + if (System.nanoTime() > deadline) { + LOGGER.warn("No screen available, cannot ask user"); + return false; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + return false; + } + } } CountDownLatch latch = new CountDownLatch(1); @@ -743,9 +757,12 @@ private static Boolean askUserAboutCertificate(InetSocketAddress address, String latch.countDown(); }; Runnable cancelCallback = latch::countDown; - new ScreenManager().validation(screen.get(), fingerprint, trustCallback, cancelCallback); + new ScreenManager().validation(parent, fingerprint, trustCallback, cancelCallback); try { - latch.await(); + if (!latch.await(120, TimeUnit.SECONDS)) { + LOGGER.warn("Certificate validation timed out for {}", address.getHostString()); + return false; + } } catch (InterruptedException e) { return false; } @@ -753,6 +770,100 @@ private static Boolean askUserAboutCertificate(InetSocketAddress address, String return accepted.get(); } + // ---- Async versions (non-blocking, used by login packet flow) ---- + + public static CompletableFuture> requestServerModpackContentAsync(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, boolean allowAskingUser) { + return fetchModpackContentAsync(modpackAddresses, secret, + (client) -> client.downloadFile(new byte[0], modpackContentTempFile, null), + "Fetched", allowAskingUser); + } + + private static CompletableFuture> fetchModpackContentAsync(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, Function> operation, String fetchType, boolean allowAskingUser) { + if (secret == null) + return CompletableFuture.completedFuture(Optional.empty()); + if (modpackAddresses.isAnyEmpty()) + throw new IllegalArgumentException("Modpack addresses are empty!"); + + return DownloadClient.createAsync(modpackAddresses, secret.secretBytes(), 1, userValidationCallbackAsync(modpackAddresses.hostAddress, allowAskingUser)) + .thenApply(client -> { + try (client) { + Path path = operation.apply(client).get(); + var content = Optional.ofNullable(ConfigTools.loadModpackContent(path)); + Files.deleteIfExists(modpackContentTempFile); + + if (content.isPresent() && potentiallyMalicious(content.get())) { + return Optional.empty(); + } + + return content; + } catch (Exception e) { + LOGGER.error("Error while getting server modpack content", e); + return Optional.empty(); + } + }) + .exceptionally(e -> { + LOGGER.error("Error while getting server modpack content", e); + return Optional.empty(); + }); + } + + public static Function> userValidationCallbackAsync(InetSocketAddress address, boolean allowAskingUser) { + return certificate -> { + String fingerprint; + try { + fingerprint = NetUtils.getFingerprint(certificate); + } catch (CertificateEncodingException e) { + return CompletableFuture.completedFuture(false); + } + if (Objects.equals(knownHosts.hosts.get(address.getHostString()), fingerprint)) + return CompletableFuture.completedFuture(true); + LOGGER.warn("Received untrusted certificate from server {}!", address.getHostString()); + if (allowAskingUser) { + return askUserAboutCertificateAsync(address, fingerprint); + } + + return CompletableFuture.completedFuture(false); + }; + } + + private static CompletableFuture askUserAboutCertificateAsync(InetSocketAddress address, String fingerprint) { + LOGGER.info("Asking user for {}", address.getHostString()); + + return CompletableFuture.supplyAsync(() -> { + Object screen = null; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (screen == null) { + screen = new ScreenManager().getScreen().orElse(null); + if (screen == null) { + if (System.nanoTime() > deadline) { + LOGGER.warn("No screen available, cannot ask user"); + return false; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + return false; + } + } + } + + CompletableFuture future = new CompletableFuture<>(); + Runnable trustAction = () -> { + knownHosts.hosts.put(address.getHostString(), fingerprint); + ConfigTools.save(knownHostsFile, knownHosts); + future.complete(true); + }; + Runnable cancelAction = () -> future.complete(false); + new ScreenManager().validation(screen, fingerprint, trustAction, cancelAction); + + try { + return future.get(120, TimeUnit.SECONDS); + } catch (Exception e) { + return false; + } + }); + } + public static boolean potentiallyMalicious(Jsons.ModpackContentFields serverModpackContent) { if (isUnsafePath(serverModpackContent.modpackName, true)) { LOGGER.error("Modpack content is invalid: modpack name '{}' is unsafe/malicious", serverModpackContent.modpackName); diff --git a/loader/loader-fabric-core.gradle.kts b/loader/loader-fabric-core.gradle.kts index d628c684f..5d981cce3 100644 --- a/loader/loader-fabric-core.gradle.kts +++ b/loader/loader-fabric-core.gradle.kts @@ -79,6 +79,7 @@ tasks.named("shadowJar") { exclude("kotlin/**", "log4j2.xml") exclude("META-INF/maven/**", "META-INF/native-image/**", "META-INF/io.netty.versions.properties") + exclude("META-INF/services/java.security.Provider") mergeServiceFiles() } diff --git a/loader/loader-forge.gradle.kts b/loader/loader-forge.gradle.kts index 674ee0cee..03bc6e50d 100644 --- a/loader/loader-forge.gradle.kts +++ b/loader/loader-forge.gradle.kts @@ -76,6 +76,7 @@ tasks.named("shadowJar") { exclude("kotlin/**", "log4j2.xml") exclude("META-INF/maven/**", "META-INF/native-image/**", "META-INF/io.netty.versions.properties") + exclude("META-INF/services/java.security.Provider") mergeServiceFiles() } diff --git a/loader/loader-neoforge.gradle.kts b/loader/loader-neoforge.gradle.kts index 9689beccc..50cb8de79 100644 --- a/loader/loader-neoforge.gradle.kts +++ b/loader/loader-neoforge.gradle.kts @@ -76,15 +76,31 @@ tasks.named("shadowJar") { exclude("kotlin/**", "log4j2.xml") exclude("META-INF/maven/**", "META-INF/native-image/**", "META-INF/io.netty.versions.properties") + exclude("META-INF/services/java.security.Provider") mergeServiceFiles() } +val moduleName = project.name.removePrefix("loader-") java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + when { + moduleName == "neoforge-fml10" -> { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } + moduleName == "neoforge-fml4" -> { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } + else -> { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + toolchain.languageVersion.set(JavaLanguageVersion.of(17)) + } + } withSourcesJar() } diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java index eb862e59b..86aa9704f 100644 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java +++ b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java @@ -1,6 +1,11 @@ package pl.skidam.automodpack_loader_core_neoforge; +import cpw.mods.modlauncher.api.LamdbaExceptionUtils; + +import java.io.InputStream; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.*; import java.util.stream.Stream; import net.neoforged.fml.loading.moddiscovery.AbstractJarFileModLocator; @@ -11,6 +16,7 @@ @SuppressWarnings("unused") public class EarlyModLocator extends AbstractJarFileModLocator { + private static final String EMBEDDED_MOD_PATH = "META-INF/jarjar/automodpack-mod.jar"; @Override public void initArguments(Map arguments) {} @@ -29,6 +35,25 @@ public Stream scanCandidates() { new Preload(); progress.complete(); - return ModpackLoader.modsToLoad.stream(); + return Stream.concat( + Stream.of(getEmbeddedModPath()), + ModpackLoader.modsToLoad.stream() + ).distinct(); + } + + private Path getEmbeddedModPath() { + return LamdbaExceptionUtils.uncheck(() -> { + Path extractedDir = Files.createTempDirectory("automodpack-fml2-embedded-"); + Path extractedJar = extractedDir.resolve("automodpack-mod.jar"); + try (InputStream stream = EarlyModLocator.class.getClassLoader().getResourceAsStream(EMBEDDED_MOD_PATH)) { + if (stream == null) { + throw new IllegalStateException("Missing embedded mod resource: " + EMBEDDED_MOD_PATH); + } + Files.copy(stream, extractedJar, StandardCopyOption.REPLACE_EXISTING); + } + extractedJar.toFile().deleteOnExit(); + extractedDir.toFile().deleteOnExit(); + return extractedJar; + }); } } diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java index 2e2382f25..9eb737a09 100644 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java +++ b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java @@ -1,60 +1,23 @@ package pl.skidam.automodpack_loader_core_neoforge; -import com.google.common.collect.ImmutableMap; import net.neoforged.fml.loading.moddiscovery.AbstractJarFileDependencyLocator; import net.neoforged.neoforgespi.locating.IModFile; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; import java.util.Map; -import static cpw.mods.modlauncher.api.LamdbaExceptionUtils.uncheck; - @SuppressWarnings("unused") public class LazyModLocator extends AbstractJarFileDependencyLocator { @Override public List scanMods(Iterable loadedMods) { - var list = new ArrayList(); - try { - list.add(getMainMod()); - } catch (Exception e) { - throw new RuntimeException(e); - } - - return list; + return List.of(); } @Override public String name() { - return null; + return "automodpack"; } @Override public void initArguments(Map arguments) { } - - // Code based on connector's https://github.com/Sinytra/Connector/blob/0514fec8f189b88c5cec54dc5632fbcee13d56dc/src/main/java/dev/su5ed/sinytra/connector/locator/EmbeddedDependencies.java#L88 - private IModFile getMainMod() throws IOException, URISyntaxException { - final Path SELF_PATH = uncheck(() -> { - URL jarLocation = LazyModLocator.class.getProtectionDomain().getCodeSource().getLocation(); - return Path.of(jarLocation.toURI()); - }); - - final String depName = "META-INF/jarjar/automodpack-mod.jar"; - - final Path pathInModFile = SELF_PATH.resolve(depName); - final URI filePathUri = new URI("jij:" + pathInModFile.toAbsolutePath().toUri().getRawSchemeSpecificPart()).normalize(); - final Map outerFsArgs = ImmutableMap.of("packagePath", pathInModFile); - final FileSystem zipFS = FileSystems.newFileSystem(filePathUri, outerFsArgs); - - final Path modPath = zipFS.getPath("/"); - var mod = createMod(modPath); - return mod.file(); - } } diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json b/loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json new file mode 100644 index 000000000..975276778 --- /dev/null +++ b/loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json @@ -0,0 +1,12 @@ +{ + "jars": [ + { + "identifier": { + "group": "pl.skidam", + "artifact": "automodpack" + }, + "path": "META-INF/jarjar/automodpack-mod.jar", + "isObfuscated": false + } + ] +} diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator deleted file mode 100644 index 4de645304..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator +++ /dev/null @@ -1 +0,0 @@ -pl.skidam.automodpack_loader_core.LazyModLocator \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c365ad2b9..7a8bc5854 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,12 +51,9 @@ stonecutter { match("1.21.8", "fabric", "neoforge") match("1.21.5", "fabric", "neoforge") match("1.21.4", "fabric", "neoforge") - match("1.21.3", "fabric", "neoforge") match("1.21.1", "fabric", "neoforge") - match("1.20.6", "fabric", "neoforge") match("1.20.4", "fabric", "neoforge") match("1.20.1", "fabric", "forge") - match("1.19.4", "fabric", "forge") match("1.19.2", "fabric", "forge") match("1.18.2", "fabric", "forge") } diff --git a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java index 29330d100..ed85e2bc2 100644 --- a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java +++ b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java @@ -10,56 +10,75 @@ import java.nio.file.Path; import java.util.Optional; -import net.minecraft.util.Util; +import java.util.concurrent.TimeUnit; import net.minecraft.client.Minecraft; +import pl.skidam.automodpack_core.Constants; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; public class ScreenImpl implements ScreenService { + private static void executeOnClient(Runnable task) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + Minecraft client; + while ((client = Minecraft.getInstance()) == null) { + if (System.nanoTime() > deadline) { + Constants.LOGGER.warn("Could not execute on client: Minecraft not yet initialized"); + return; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + client.execute(task); + } + @Override public void download(Object... args) { - Minecraft.getInstance().execute(() -> Screens.download(args[0], args[1])); + executeOnClient(() -> Screens.download(args[0], args[1])); } @Override public void fetch(Object... args) { - Minecraft.getInstance().execute(() -> Screens.fetch(args[0])); + executeOnClient(() -> Screens.fetch(args[0])); } @Override public void changelog(Object... args) { - Minecraft.getInstance().execute(() -> Screens.changelog(args[0], args[1], args[2])); + executeOnClient(() -> Screens.changelog(args[0], args[1], args[2])); } @Override public void restart(Object... args) { - Minecraft.getInstance().execute(() -> Screens.restart(args[0], args[1], args[2])); + executeOnClient(() -> Screens.restart(args[0], args[1], args[2])); } @Override public void danger(Object... args) { - Minecraft.getInstance().execute(() -> Screens.danger(args[0], args[1])); + executeOnClient(() -> Screens.danger(args[0], args[1])); } @Override public void error(String... args) { - Minecraft.getInstance().execute(() -> Screens.error(args)); + executeOnClient(() -> Screens.error(args)); } @Override public void menu(Object... args) { - Minecraft.getInstance().execute(Screens::menu); + executeOnClient(Screens::menu); } @Override public void title(Object... args) { - Minecraft.getInstance().execute(Screens::title); + executeOnClient(Screens::title); } @Override public void validation(Object... args) { - Minecraft.getInstance().execute(() -> Screens.validation(args[0], args[1], args[2], args[3])); + executeOnClient(() -> Screens.validation(args[0], args[1], args[2], args[3])); } @Override @@ -83,12 +102,7 @@ private static Screen getScreen() { } public static void setScreen(Screen screen) { - // required for forge to handle it properly - /*? if >=26.2 {*/ - Util.backgroundExecutor().execute(() -> Minecraft.getInstance().execute(() -> Minecraft.getInstance().gui.setScreen(screen))); - /*?} else {*/ - /*Util.backgroundExecutor().execute(() -> Minecraft.getInstance().execute(() -> Minecraft.getInstance().setScreen(screen))); - *//*?}*/ + Minecraft.getInstance().setScreen(screen); } public static void download(Object downloadManager, Object header) { diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java new file mode 100644 index 000000000..f67af9359 --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -0,0 +1,255 @@ +package pl.skidam.automodpack.client.autotest; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.ConnectScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.resolver.ServerAddress; +/*? if >= 1.20.5 {*/ +import net.minecraft.client.multiplayer.TransferState; +/*?}*/ +import pl.skidam.automodpack.client.ui.FingerprintVerificationScreen; +import pl.skidam.automodpack_core.Constants; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static pl.skidam.automodpack_core.Constants.LOGGER; + +public final class AutoTestBridge { + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + + private AutoTestBridge() {} + + public static void startIfEnabled() { + if (!Boolean.getBoolean("automodpack.autotest")) return; + if (!STARTED.compareAndSet(false, true)) return; + String token = System.getProperty("automodpack.autotest.token", ""); + String gameDir = System.getProperty("automodpack.autotest.gamedir", ""); + if (token.isBlank() || gameDir.isBlank()) { + LOGGER.warn("AutoModpack bridge disabled: token is '{}', gamedir is '{}'", token, gameDir); + return; + } + Thread t = new Thread(() -> run(Path.of(gameDir), token), "AutoModpackBridge"); + t.setDaemon(true); + t.start(); + } + + private static void run(Path gameDir, String token) { + Path dir = gameDir.resolve("automodpack/autotest"); + try { Files.createDirectories(dir); } catch (IOException e) { + LOGGER.error("Cannot create autotest dir", e); + return; + } + try { writeFile(dir.resolve("bridge-state.json"), "{\"status\":\"ready\"}"); } catch (IOException e) { + LOGGER.error("Cannot write bridge state", e); + return; + } + LOGGER.info("AutoModpack bridge ready at {}", dir); + Path cmd = dir.resolve("bridge-command.json"); + Path rsp = dir.resolve("bridge-response.json"); + while (true) { + try { + if (Files.exists(cmd)) { + String json = Files.readString(cmd, StandardCharsets.UTF_8); + Files.delete(cmd); + String response; + try { + JsonObject req = JsonParser.parseString(json).getAsJsonObject(); + if (!token.equals(optString(req, "token"))) { + response = err("Authentication failed: invalid bridge token"); + } else { + response = exec(req); + } + } catch (Exception e) { + LOGGER.error("Bridge exec error", e); + response = err(e.getMessage()); + } + writeFile(rsp, response); + } + Thread.sleep(100); + } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } + catch (Exception e) { LOGGER.error("Bridge error", e); } + } + } + + private static String exec(JsonObject req) throws Exception { + return switch (optString(req, "op")) { + case "ping" -> ok(); + case "get_screen" -> execOnMain(() -> scr(false)); + case "get_widgets" -> execOnMain(() -> scr(true)); + case "connect" -> connect(req); + case "wait_fingerprint" -> execOnMain(() -> ok()); + case "set_text" -> execOnMain(() -> { + Object w = widget(req); + if (w instanceof EditBox e) { + e.setValue(optString(req, "text")); + Screen s = Minecraft.getInstance().screen; + if (s instanceof FingerprintVerificationScreen fps) { + fps.setInputText(optString(req, "text")); + } + } + return ok(); + }); + case "click" -> execOnMain(() -> { + Object w = widget(req); + if (w instanceof Button b) { + /*? if >= 1.21.10 {*/ + var input = new net.minecraft.client.input.InputWithModifiers() { + public int input() { return 0; } + public int modifiers() { return 0; } + }; + b.onPress(input); + /*?} else {*/ + /*b.onPress();*/ + /*?}*/ + } + return ok(); + }); + case "set_screen" -> execOnMain(() -> { Minecraft.getInstance().setScreen(new TitleScreen()); return ok(); }); + case "verify_fingerprint" -> execOnMain(() -> { + Screen s = Minecraft.getInstance().screen; + if (s instanceof FingerprintVerificationScreen fps) { + String fp = optString(req, "fingerprint"); + List widgets = collectWidgets(); + for (Object w : widgets) { + if (w instanceof EditBox e) { + e.setValue(fp); + break; + } + } + fps.setInputText(fp); + fps.verifyFingerprint(); + return ok(); + } + return err("not on FingerprintVerificationScreen"); + }); + case "quit" -> { + Minecraft.getInstance().execute(() -> Minecraft.getInstance().stop()); + yield ok(); + } + default -> err("Unknown bridge operation: '" + optString(req, "op") + "'"); + }; + } + + private static String scr(boolean detailed) { + Screen s = Minecraft.getInstance().screen; + JsonObject o = new JsonObject(); + o.addProperty("ok", true); + o.addProperty("screenClass", s == null ? null : s.getClass().getName()); + o.addProperty("title", s == null ? null : s.getTitle().getString()); + if (detailed && s != null) { + JsonArray a = new JsonArray(); + int i = 0; + for (Object w : collectWidgets()) { + if (!(w instanceof AbstractWidget aw)) continue; + JsonObject wo = new JsonObject(); + wo.addProperty("id", i++); + wo.addProperty("type", aw instanceof Button ? "Button" : aw instanceof EditBox ? "EditBox" : aw.getClass().getSimpleName()); + wo.addProperty("class", aw.getClass().getName()); + wo.addProperty("text", aw.getMessage().getString()); + /*? if >= 1.19.4 {*/ + wo.addProperty("x", aw.getX()); wo.addProperty("y", aw.getY()); + /*?} else {*/ + /*wo.addProperty("x", aw.x); wo.addProperty("y", aw.y);*/ + /*?}*/ + wo.addProperty("active", aw.active); wo.addProperty("visible", aw.visible); + a.add(wo); + } + o.add("widgets", a); + } + return o.toString(); + } + + private static String connect(JsonObject req) throws Exception { + String addr = optString(req, "host") + ":" + optInt(req, "port", 25565); + Minecraft c; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while ((c = Minecraft.getInstance()) == null) { + if (System.nanoTime() > deadline) return err("Minecraft not initialized"); + Thread.sleep(100); + } + final Minecraft captured = c; + if (captured.getOverlay() != null) { + deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120); + while (captured.getOverlay() != null && System.nanoTime() < deadline) Thread.sleep(100); + } + CompletableFuture f = new CompletableFuture<>(); + captured.execute(() -> { + try { + /*? if >= 1.20.5 {*/ + ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, ServerData.Type.OTHER), false, (TransferState) null); + /*?} else if >= 1.20.4 {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, ServerData.Type.OTHER), false); + *//*?} else if >= 1.20.1 {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, false), false); + */ /*?} else {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, false)); + *//*?}*/ + f.complete(ok()); + } catch (Exception e) { f.complete(err(e.getMessage())); } + }); + return f.get(30, TimeUnit.SECONDS); + } + + private static List collectWidgets() { + Screen s = Minecraft.getInstance().screen; + if (s == null) return List.of(); + return List.copyOf(s.children().stream().filter(w -> w instanceof AbstractWidget).toList()); + } + + private static Object widget(JsonObject req) { + List all = collectWidgets(); + if (all.isEmpty()) throw new NullPointerException("no widgets"); + int wid = optInt(req, "widgetId", -1); + JsonObject sel = req.getAsJsonObject("selector"); + if (wid < 0 && sel != null) wid = optInt(sel, "widgetId", -1); + if (wid >= 0 && wid < all.size()) return all.get(wid); + String selType = sel != null ? optString(sel, "type") : null; + String selText = sel != null ? optString(sel, "text") : optString(req, "text"); + int idx = sel != null ? optInt(sel, "index", -1) : -1; + var cand = selType != null && !selType.isEmpty() ? all.stream().filter(w -> (w instanceof Button ? "Button" : w instanceof EditBox ? "EditBox" : "").equalsIgnoreCase(selType)).toList() : all; + if (selText != null && !selText.isEmpty()) { + for (Object w : cand) { if (AbstractWidget.class.cast(w).getMessage().getString().equalsIgnoreCase(selText)) return w; } + for (Object w : cand) { if (AbstractWidget.class.cast(w).getMessage().getString().toLowerCase().contains(selText.toLowerCase())) return w; } + } + if (idx >= 0 && idx < cand.size()) return cand.get(idx); + if (!cand.isEmpty()) return cand.get(0); + throw new IllegalArgumentException("widget not found"); + } + + private static String execOnMain(ThrowingSupplier t) throws Exception { + CompletableFuture f = new CompletableFuture<>(); + Minecraft.getInstance().execute(() -> { try { f.complete(t.get()); } catch (Exception e) { f.completeExceptionally(e); } }); + return f.get(60, TimeUnit.SECONDS); + } + + private static void writeFile(Path p, String c) throws IOException { + Path t = p.resolveSibling(p.getFileName() + ".tmp"); + Files.writeString(t, c, StandardCharsets.UTF_8); + Files.move(t, p, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + + private static String ok() { return "{\"ok\":true}"; } + private static String err(String m) { return "{\"ok\":false,\"error\":\"" + (m != null ? m.replace("\\", "\\\\").replace("\"", "\\\"") : "unknown") + "\"}"; } + private static String optString(JsonObject o, String k) { JsonElement e = o.get(k); return e != null && !e.isJsonNull() ? e.getAsString() : ""; } + private static int optInt(JsonObject o, String k, int d) { JsonElement e = o.get(k); return e != null && !e.isJsonNull() ? e.getAsInt() : d; } + + @FunctionalInterface + private interface ThrowingSupplier { T get() throws Exception; } +} diff --git a/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java b/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java index e9d97b763..f52f64f3e 100644 --- a/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java +++ b/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java @@ -9,10 +9,13 @@ import pl.skidam.automodpack.client.ui.versioned.VersionedText; import pl.skidam.automodpack_loader_core.client.ModpackUpdater; +import java.util.concurrent.atomic.AtomicBoolean; + public class DangerScreen extends VersionedScreen { private final Screen parent; private final ModpackUpdater modpackUpdaterInstance; + private final AtomicBoolean updateStarted = new AtomicBoolean(false); public DangerScreen(Screen parent, ModpackUpdater modpackUpdaterInstance) { super(VersionedText.literal("DangerScreen")); @@ -48,7 +51,11 @@ protected void init() { VersionedText.translatable( "automodpack.danger.confirm" ).withStyle(ChatFormatting.BOLD), - button -> Util.backgroundExecutor().execute(() -> modpackUpdaterInstance.startUpdate(modpackUpdaterInstance.getModpackFileList())) + button -> { + if (updateStarted.compareAndSet(false, true)) { + Util.backgroundExecutor().execute(() -> modpackUpdaterInstance.startUpdate(modpackUpdaterInstance.getModpackFileList())); + } + } ) ); } @@ -102,7 +109,7 @@ public void versionedRender(VersionedMatrices matrices, int mouseX, int mouseY, @Override public boolean onKeyPress(int keyCode, int scanCode, int modifiers) { - if (keyCode == 257) { // Enter key (GLFW_KEY_ENTER = 257) + if (keyCode == 257 && updateStarted.compareAndSet(false, true)) { // Enter key (GLFW_KEY_ENTER = 257) Util.backgroundExecutor().execute(() -> modpackUpdaterInstance.startUpdate(modpackUpdaterInstance.getModpackFileList())); return true; } diff --git a/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java b/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java index 33b3e9884..bfd039a34 100644 --- a/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java +++ b/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java @@ -18,6 +18,7 @@ public class FingerprintVerificationScreen extends VersionedScreen { private final Runnable validatedCallback; private final Runnable canceledCallback; private boolean validated = false; + private String inputText = ""; private final Toast failedToast = new SystemToast(SystemToast.SystemToastId.PACK_LOAD_FAILURE, VersionedText.translatable("automodpack.validation.failed"), VersionedText.translatable("automodpack.retry")); @@ -40,6 +41,9 @@ protected void init() { super.init(); initWidgets(); + if (!inputText.isEmpty()) { + this.textField.setValue(inputText); + } this.addRenderableWidget(this.textField); this.addRenderableWidget(this.backButton); @@ -90,16 +94,28 @@ public void initWidgets() { setTooltip(wikiButton, VersionedText.translatable("automodpack.learnmore")); } - private void verifyFingerprint() { + public void forceValidate() { + verifyButton.active = false; + this.validated = true; + this.inputText = ""; + if (this.minecraft != null) { + this.minecraft.setScreen(parent); + } + validatedCallback.run(); + } + + public void setInputText(String text) { + this.inputText = text; + if (this.textField != null) { + this.textField.setValue(text); + } + } + + public void verifyFingerprint() { String input = textField.getValue().strip(); - + inputText = input; if (input.equals(serverFingerprint)) { - verifyButton.active = false; - this.validated = true; - if (this.minecraft != null) { - this.minecraft.gui.setScreen(parent); - } - validatedCallback.run(); + forceValidate(); } else { Constants.LOGGER.error("Server fingerprint validation failed, try again"); if (this.minecraft != null) { diff --git a/src/main/java/pl/skidam/automodpack/init/FabricInit.java b/src/main/java/pl/skidam/automodpack/init/FabricInit.java index 92c675613..93998cb9c 100644 --- a/src/main/java/pl/skidam/automodpack/init/FabricInit.java +++ b/src/main/java/pl/skidam/automodpack/init/FabricInit.java @@ -2,6 +2,7 @@ /*? if fabric {*/ import pl.skidam.automodpack.client.ScreenImpl; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; import pl.skidam.automodpack.client.audio.AudioManager; import pl.skidam.automodpack.modpack.Commands; import pl.skidam.automodpack.networking.ModPackets; @@ -28,6 +29,7 @@ public static void onInitialize() { } else { ModPackets.registerC2SPackets(); new AudioManager(); + AutoTestBridge.startIfEnabled(); } CommandRegistrationCallback.EVENT.register((dispatcher, /*? if >=1.19.1 {*/ w, /*?}*/ dedicated) -> { @@ -37,4 +39,4 @@ public static void onInitialize() { LOGGER.info("AutoModpack launched! took " + (System.currentTimeMillis() - start) + "ms"); } } -/*?}*/ \ No newline at end of file +/*?}*/ diff --git a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java index 509adccd6..9dd7a0085 100644 --- a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java @@ -2,6 +2,7 @@ /*? if forge {*/ /*import pl.skidam.automodpack.client.ScreenImpl; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; import pl.skidam.automodpack.client.audio.AudioManager; import pl.skidam.automodpack.modpack.Commands; import pl.skidam.automodpack.networking.ModPackets; @@ -32,6 +33,7 @@ public ForgeInit() { } else { ModPackets.registerC2SPackets(); new AudioManager(FMLJavaModLoadingContext.get().getModEventBus()); + AutoTestBridge.startIfEnabled(); } @@ -46,4 +48,4 @@ public static void onCommandsRegister(RegisterCommandsEvent event) { } } } -*//*?}*/ \ No newline at end of file +*//*?}*/ diff --git a/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java b/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java index 5a27ccbcd..3f79a0d40 100644 --- a/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java @@ -5,6 +5,7 @@ import net.neoforged.fml.common.EventBusSubscriber; /^?}^/ import pl.skidam.automodpack.client.ScreenImpl; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; import pl.skidam.automodpack.client.audio.AudioManager; import pl.skidam.automodpack.modpack.Commands; import pl.skidam.automodpack.networking.ModPackets; @@ -34,6 +35,7 @@ public NeoForgeInit(IEventBus eventBus) { } else { ModPackets.registerC2SPackets(); new AudioManager(eventBus); + AutoTestBridge.startIfEnabled(); } diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java index 70e305445..3c81b64d7 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java @@ -1,8 +1,7 @@ package pl.skidam.automodpack.mixin.core; -import net.fabricmc.fabric.impl.networking.server.ServerLoginNetworkAddon; import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; -import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceLocation; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Pseudo; import org.spongepowered.asm.mixin.injection.At; @@ -11,7 +10,7 @@ import pl.skidam.automodpack.networking.LoginNetworkingIDs; @Pseudo -@Mixin(value = ServerLoginNetworkAddon.class, remap = false) +@Mixin(targets = "net.fabricmc.fabric.impl.networking.server.ServerLoginNetworkAddon", remap = false) public class FabricLoginMixin { @Inject( @@ -23,7 +22,7 @@ private void dontRemoveAutoModpackChannels(ClientboundCustomQueryPacket packet, /*? if <1.20.2 {*/ /*Identifier id = packet.getIdentifier(); *//*?} else {*/ - Identifier id = packet.payload().id(); + ResourceLocation id = packet.payload().id(); /*?}*/ // Cancel if it's one of our channels if (LoginNetworkingIDs.getByKey(id) != null) { diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java index 15a325b36..08a6eea8c 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java @@ -1,6 +1,7 @@ package pl.skidam.automodpack.mixin.core; import org.spongepowered.asm.mixin.Mixin; + /*? if >=1.20.2 {*/ import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; @@ -15,15 +16,9 @@ import pl.skidam.automodpack.networking.server.LoginRequestPayload; import pl.skidam.automodpack_core.Constants; -// TODO find better way to do this, its mixin only for 1.20.2 and above @Mixin(value = ClientboundCustomQueryPacket.class, priority = 300) -/*?} else {*/ -/*import pl.skidam.automodpack.init.Common; -@Mixin(Common.class) -*//*?}*/ public class LoginQueryRequestS2CPacketMixin { -/*? if >=1.20.2 {*/ @Shadow @Final private static int MAX_PAYLOAD_SIZE; @Inject(method = "readPayload", at = @At("HEAD"), cancellable = true) @@ -32,5 +27,12 @@ private static void readPayload(Identifier id, FriendlyByteBuf buf, CallbackInfo cir.setReturnValue(new LoginRequestPayload(id, PayloadHelper.read(buf, MAX_PAYLOAD_SIZE))); } } -/*?}*/ -} \ No newline at end of file +} +/*?} else {*/ +/*import net.minecraft.core.BlockPos; + +@Mixin(BlockPos.class) +public class LoginQueryRequestS2CPacketMixin { + // No-op: this mixin is only needed for 1.20.2+ readPayload injection +} +*//*?}*/ \ No newline at end of file diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java index d845e989d..b1b4391db 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java @@ -1,6 +1,7 @@ package pl.skidam.automodpack.mixin.core; import org.spongepowered.asm.mixin.Mixin; + /*? if >=1.20.2 {*/ import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; @@ -15,14 +16,9 @@ import pl.skidam.automodpack.networking.PayloadHelper; import pl.skidam.automodpack.networking.client.LoginResponsePayload; -// TODO find better way to do this, its mixin only for 1.20.2 and above @Mixin(value = ServerboundCustomQueryAnswerPacket.class, priority = 300) -/*?} else {*/ -/*import pl.skidam.automodpack.init.Common; -@Mixin(Common.class) -*//*?}*/ public class LoginQueryResponseC2SPacketMixin { -/*? if >=1.20.2 {*/ + @Shadow @Final private static int MAX_PAYLOAD_SIZE; @@ -43,5 +39,12 @@ private static void readResponse(int queryId, FriendlyByteBuf buf, CallbackInfoR cir.setReturnValue(new LoginResponsePayload(automodpackID, PayloadHelper.read(buf, MAX_PAYLOAD_SIZE))); } -/*?}*/ -} \ No newline at end of file +} +/*?} else {*/ +/*import net.minecraft.core.BlockPos; + +@Mixin(BlockPos.class) +public class LoginQueryResponseC2SPacketMixin { + // No-op: this mixin is only needed for 1.20.2+ readPayload injection +} +*//*?}*/ \ No newline at end of file diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 2f05f756d..e524629dc 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -27,106 +27,127 @@ import static pl.skidam.automodpack_core.config.ConfigTools.GSON; public class DataC2SPacket { - public static CompletableFuture receive(Minecraft Minecraft, ClientHandshakePacketListenerImpl handler, FriendlyByteBuf buf) { + public static CompletableFuture receive(Minecraft client, ClientHandshakePacketListenerImpl handler, FriendlyByteBuf buf) { + DataPacket dataPacket; try { String serverResponse = buf.readUtf(Short.MAX_VALUE); - DataPacket dataPacket = DataPacket.fromJson(serverResponse); - - String packetAddress = dataPacket.address; - int packetPort = dataPacket.port; - String modpackName = dataPacket.modpackName; - Secrets.Secret secret = dataPacket.secret; - boolean modRequired = dataPacket.modRequired; - boolean requiresMagic = dataPacket.requiresMagic; - - if (modRequired) { - // TODO set screen to refreshed danger screen which will ask user to install modpack with two options - // 1. Disconnect and install modpack - // 2. Dont disconnect and join server - } - - InetSocketAddress serverAddress = ModPackets.getOriginalServerAddress(); - ModPackets.setOriginalServerAddress(null); // Reset for next server reconnection - if (serverAddress == null) { - LOGGER.error("Server address is null! Something gone very wrong! Please report this issue! https://github.com/Skidamek/AutoModpack/issues"); - return CompletableFuture.completedFuture(new FriendlyByteBuf(Unpooled.buffer())); - } - - // Get actual address of the server client have connected to and format it - InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); - String effectiveHost; - int effectivePort; - - // If the packet specifies a non-blank address, use it or else use address from the server client have connected to. - // Important! Use getAddress().getHostAddress() instead of getHostString() - // because Minecraft creates connectedAddress instance through a constructor which attempts a reverse DNS lookup - // which resolves PTR record for the IP address and stores the resolved hostname in the hostname field. - if (packetAddress.isBlank()) { - effectiveHost = connectedAddress.getAddress().getHostAddress(); - } else { - effectiveHost = packetAddress; - } - - if (packetPort == -1) { - effectivePort = connectedAddress.getPort(); - } else { - effectivePort = packetPort; - } - - // Construct the final modpack address - InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); - - LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); - - Boolean needsDisconnecting = null; - FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); - - Path modpackDir = ModpackUtils.getModpackPath(modpackAddress, modpackName); - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(modpackAddress, serverAddress, requiresMagic); - var optionalServerModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, true); - - if (optionalServerModpackContent.isPresent()) { - ModpackUtils.UpdateCheckResult updateCheckResult = ModpackUtils.isUpdate(optionalServerModpackContent.get(), modpackDir); - - if (updateCheckResult.requiresUpdate()) { - disconnectImmediately(handler); - new ModpackUpdater(optionalServerModpackContent.get(), modpackAddresses, secret, modpackDir).processModpackUpdate(updateCheckResult); - needsDisconnecting = true; - } else { - boolean selectedModpackChanged = ModpackUtils.selectModpack(modpackDir, modpackAddresses, Set.of()); - - // save latest modpack content - var modpackContentFile = modpackDir.resolve(hostModpackContentFile.getFileName()); - if (Files.exists(modpackContentFile)) { - Files.writeString(modpackContentFile, GSON.toJson(optionalServerModpackContent.get())); + dataPacket = DataPacket.fromJson(serverResponse); + } catch (Exception e) { + LOGGER.error("Error parsing data packet", e); + FriendlyByteBuf error = new FriendlyByteBuf(Unpooled.buffer()); + error.writeUtf("null", Short.MAX_VALUE); + return CompletableFuture.completedFuture(error); + } + + String packetAddress = dataPacket.address; + int packetPort = dataPacket.port; + String modpackName = dataPacket.modpackName; + Secrets.Secret secret = dataPacket.secret; + boolean modRequired = dataPacket.modRequired; + boolean requiresMagic = dataPacket.requiresMagic; + + if (modRequired) { + // TODO set screen to refreshed danger screen which will ask user to install modpack with two options + // 1. Disconnect and install modpack + // 2. Dont disconnect and join server + } + + InetSocketAddress serverAddress = ModPackets.getOriginalServerAddress(); + ModPackets.setOriginalServerAddress(null); // Reset for next server reconnection + if (serverAddress == null) { + LOGGER.error("Server address is null! Something gone very wrong! Please report this issue! https://github.com/Skidamek/AutoModpack/issues"); + return CompletableFuture.completedFuture(new FriendlyByteBuf(Unpooled.buffer())); + } + + // Get actual address of the server client have connected to and format it + InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); + String effectiveHost; + int effectivePort; + + if (packetAddress.isBlank()) { + effectiveHost = connectedAddress.getAddress().getHostAddress(); + } else { + effectiveHost = packetAddress; + } + + if (packetPort == -1) { + effectivePort = connectedAddress.getPort(); + } else { + effectivePort = packetPort; + } + + InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); + + LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); + + Path modpackDir = ModpackUtils.getModpackPath(modpackAddress, modpackName); + Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(modpackAddress, serverAddress, requiresMagic); + + return ModpackUtils.requestServerModpackContentAsync(modpackAddresses, secret, true) + .thenApply(optionalServerModpackContent -> { + long t0 = System.currentTimeMillis(); + + if (optionalServerModpackContent.isEmpty()) { + if (ModpackUtils.canConnectModpackHost(modpackAddresses)) { + return buildResponse(true); + } } - if (selectedModpackChanged) { + Boolean needsDisconnecting = null; + + if (optionalServerModpackContent.isPresent()) { + ModpackUtils.UpdateCheckResult updateCheckResult = ModpackUtils.isUpdate(optionalServerModpackContent.get(), modpackDir); + + if (updateCheckResult.requiresUpdate()) { + LOGGER.info("DataC2SPacket: update required, disconnecting immediately (t={})", System.currentTimeMillis() - t0); + disconnectImmediately(handler); + LOGGER.info("DataC2SPacket: disconnected, calling processModpackUpdate (t={})", System.currentTimeMillis() - t0); + new ModpackUpdater(optionalServerModpackContent.get(), modpackAddresses, secret, modpackDir).processModpackUpdate(updateCheckResult); + LOGGER.info("DataC2SPacket: processModpackUpdate returned (t={})", System.currentTimeMillis() - t0); + needsDisconnecting = true; + } else { + boolean selectedModpackChanged = ModpackUtils.selectModpack(modpackDir, modpackAddresses, Set.of()); + + var modpackContentFile = modpackDir.resolve(hostModpackContentFile.getFileName()); + if (Files.exists(modpackContentFile)) { + try { + Files.writeString(modpackContentFile, GSON.toJson(optionalServerModpackContent.get())); + } catch (Exception ignored) {} + } + + if (selectedModpackChanged) { + SecretsStore.saveClientSecret(clientConfig.selectedModpack, secret); + disconnectImmediately(handler); + new ReLauncher(modpackDir, UpdateType.SELECT, null).restart(false); + needsDisconnecting = true; + } else { + needsDisconnecting = false; + } + } + } + + if (clientConfig.selectedModpack != null && !clientConfig.selectedModpack.isBlank()) { SecretsStore.saveClientSecret(clientConfig.selectedModpack, secret); - disconnectImmediately(handler); - new ReLauncher(modpackDir, UpdateType.SELECT, null).restart(false); - needsDisconnecting = true; - } else { - needsDisconnecting = false; } - } - } else if (ModpackUtils.canConnectModpackHost(modpackAddresses)) { // Can't download modpack because e.g. certificate is not verified but it can connect to the modpack host - needsDisconnecting = true; - } - if (clientConfig.selectedModpack != null && !clientConfig.selectedModpack.isBlank()) { - SecretsStore.saveClientSecret(clientConfig.selectedModpack, secret); - } + return buildResponse(needsDisconnecting); + }) + .exceptionally(e -> { + LOGGER.error("Error while handling data packet", e); + FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); + response.writeUtf("null", Short.MAX_VALUE); + return response; + }); + } + private static FriendlyByteBuf buildResponse(Boolean needsDisconnecting) { + FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); + if (needsDisconnecting != null) { response.writeUtf(String.valueOf(needsDisconnecting), Short.MAX_VALUE); - - return CompletableFuture.completedFuture(response); - } catch (Exception e) { - LOGGER.error("Error while handling data packet", e); - FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); + } else { response.writeUtf("null", Short.MAX_VALUE); - return CompletableFuture.completedFuture(new FriendlyByteBuf(Unpooled.buffer())); } + return response; } private static void disconnectImmediately(ClientHandshakePacketListenerImpl clientLoginNetworkHandler) { diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index 83f430c99..bd3dc15d8 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -82,18 +82,6 @@ meta.minecraft = "1.21.4" publish_versions = "1.21.4" -["1.21.3-fabric"] -deps.fabric-api = "0.114.1+1.21.3" - -["1.21.3-neoforge"] -deps.neoforge = "21.3.76" - -["1.21.3"] -deps.minecraft = "1.21.3" -meta.minecraft = ">=1.21.2 <=1.21.3" -publish_versions = "1.21.2\n1.21.3" - - ["1.21.1-fabric"] deps.fabric-api = "0.116.7+1.21.1" @@ -106,18 +94,6 @@ meta.minecraft = ">=1.21 <=1.21.1" publish_versions = "1.21\n1.21.1" -["1.20.6-fabric"] -deps.fabric-api = "0.100.8+1.20.6" - -["1.20.6-neoforge"] -deps.neoforge = "20.6.135" - -["1.20.6"] -deps.minecraft = "1.20.6" -meta.minecraft = "1.20.6" -publish_versions = "1.20.6" - - ["1.20.4-fabric"] deps.fabric-api = "0.97.3+1.20.4" @@ -142,18 +118,6 @@ meta.minecraft = ">=1.20 <=1.20.1" publish_versions = "1.20\n1.20.1" -["1.19.4-fabric"] -deps.fabric-api = "0.87.2+1.19.4" - -["1.19.4-forge"] -deps.forge = "1.19.4-45.3.0" - -["1.19.4"] -deps.minecraft = "1.19.4" -meta.minecraft = "1.19.4" -publish_versions = "1.19.4" - - ["1.19.2-fabric"] deps.fabric-api = "0.76.1+1.19.2" From eef9b633116687c535b8578f44998cb19320b35c Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 14:26:37 +0200 Subject: [PATCH 02/44] autotester: Docker-based in-game integration test framework Adds a docker-based autotest framework that runs real Minecraft server + client containers, drives the UI through a file-based JSON bridge (AutoTestBridge), and validates the modpack sync flow against 22 version/loader targets. Includes mod-side changes required to expose UI state for testing: AutoTestBridge, async certificate trust helpers, EditBox inputText persistence, and infrastructure cleanup for unsupported MC versions. --- autotester/README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/autotester/README.md b/autotester/README.md index 5d4bcc9a0..a68fa1dd8 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -29,15 +29,18 @@ aggregated and written to `results.json`. ## CLI reference +All commands run from the repo root: + ``` -uv run autotester build-images [--client-image IMG] [--headlessmc-version VER] -uv run autotester run [--target ID | all] [--scenario ID] [--jobs N] - [--docker-uid UID] [--docker-gid GID] - [--artifact-dir PATH] [--out-dir PATH] - [--client-image IMG] -uv run autotester clean [--out-dir PATH] +uv --project autotester run autotester build-images [--client-image IMG] [--headlessmc-version VER] +uv --project autotester run autotester run [--target ID | all] [--scenario ID] [--jobs N] + [--docker-uid UID] [--docker-gid GID] + [--artifact-dir PATH] [--out-dir PATH] + [--client-image IMG] +uv --project autotester run autotester clean [--out-dir PATH] ``` +(Or `cd autotester` and use `uv run autotester ...` instead.) ### build-images Builds the client Docker image (Java + HeadlessMC). @@ -407,7 +410,11 @@ topology: dependencies: true ``` +<<<<<<< HEAD Then run: `uv run autotester run --scenario my-test` +======= +Then run: `uv --project autotester run autotester run --scenario my-test` +>>>>>>> 4d2f314c (autotester: Docker-based in-game integration test framework) ## CI (GitHub Actions) From f2f2ca37db10964bc8f09705a6727482e05d700f Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 18:05:52 +0200 Subject: [PATCH 03/44] move manual docker managment to a proper python docker lib --- .gitignore | 3 - autotester/automodpack_autotester/cli.py | 40 ++-- autotester/automodpack_autotester/docker.py | 114 ----------- autotester/automodpack_autotester/runner.py | 207 +++++++++++--------- autotester/pyproject.toml | 1 + autotester/uv.lock | 170 +++++++++++++++- 6 files changed, 306 insertions(+), 229 deletions(-) delete mode 100644 autotester/automodpack_autotester/docker.py diff --git a/.gitignore b/.gitignore index 6f4263587..156b09c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,3 @@ __pycache__/ *.egg-info/ autotester/out/ autotester/.hmc-cache/ -# build output -/pl/ -/META-INF/ diff --git a/autotester/automodpack_autotester/cli.py b/autotester/automodpack_autotester/cli.py index f23b4445b..786bed416 100644 --- a/autotester/automodpack_autotester/cli.py +++ b/autotester/automodpack_autotester/cli.py @@ -4,13 +4,13 @@ import logging import os import shutil -import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path +import docker as docker_py + from .config import REPO_ROOT, ROOT, load_scenarios, load_settings, load_targets -from .docker import Docker from .runner import run_case logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") @@ -24,20 +24,17 @@ def _resolve_settings_path(s: dict, key: str, default: str) -> Path: def _kill_amp_containers() -> None: - r = subprocess.run( - ["docker", "ps", "-q", "-a", "--filter", "name=amp-"], - capture_output=True, text=True, check=False, - ) - for cid in r.stdout.strip().split(): - if cid: - subprocess.run(["docker", "rm", "-f", cid], check=False, capture_output=True) - r = subprocess.run( - ["docker", "network", "ls", "-q", "--filter", "name=amp-"], - capture_output=True, text=True, check=False, - ) - for nid in r.stdout.strip().split(): - if nid: - subprocess.run(["docker", "network", "rm", nid], check=False, capture_output=True) + client = docker_py.from_env() + for c in client.containers.list(all=True, filters={"name": "amp-"}): + try: + c.remove(force=True) + except Exception: + pass + for n in client.networks.list(filters={"name": "amp-"}): + try: + n.remove() + except Exception: + pass def main(argv: list[str] | None = None) -> int: @@ -73,11 +70,12 @@ def main(argv: list[str] | None = None) -> int: img = args.client_image or str( s.get("images", {}).get("client", "automodpack-autotest-client:local") ) - Docker().build( - img, - ROOT / "docker" / "client" / "Dockerfile", - ROOT / "docker" / "client", - {"HEADLESSMC_VERSION": ver}, + docker_py.from_env().images.build( + path=str(ROOT / "docker" / "client"), + dockerfile=str(ROOT / "docker" / "client" / "Dockerfile"), + tag=img, + buildargs={"HEADLESSMC_VERSION": ver}, + rm=True, ) return 0 diff --git a/autotester/automodpack_autotester/docker.py b/autotester/automodpack_autotester/docker.py deleted file mode 100644 index 83f6bc873..000000000 --- a/autotester/automodpack_autotester/docker.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping - - -@dataclass -class Container: - name: str - id: str - - -def _run( - args: list[str], *, check: bool = True, capture: bool = False -) -> subprocess.CompletedProcess[str]: - try: - return subprocess.run(args, check=check, text=True, capture_output=capture) - except subprocess.CalledProcessError as e: - msg = str(e) - if e.stderr: - msg += f"\n stderr: {e.stderr.strip()}" - raise RuntimeError(msg) from e - - -def _output(args: list[str]) -> str: - cp = _run(args, capture=True) - return (cp.stdout or "") + (cp.stderr or "") - - -class Docker: - def build( - self, - tag: str, - dockerfile: Path, - context: Path, - build_args: Mapping[str, str] | None = None, - ) -> None: - args = ["docker", "build", "-t", tag, "-f", str(dockerfile)] - for k, v in (build_args or {}).items(): - args += ["--build-arg", f"{k}={v}"] - _run(args + [str(context)]) - - def create_network(self, name: str) -> None: - _run(["docker", "network", "rm", name], check=False, capture=True) - _run(["docker", "network", "create", name]) - - def remove_network(self, name: str) -> None: - _run(["docker", "network", "rm", name], check=False, capture=True) - - def ensure_volume(self, name: str) -> None: - _run(["docker", "volume", "create", name]) - - def remove_volume(self, name: str) -> None: - _run(["docker", "volume", "rm", name], check=False, capture=True) - - def remove_container(self, name: str) -> None: - _run(["docker", "rm", "-f", name], check=False, capture=True) - - def run_detached( - self, - *, - name: str, - image: str, - network: str, - env: Mapping[str, str], - mounts: list[tuple[Path | str, str, bool]], - command: list[str] | None = None, - user: str | None = None, - ) -> Container: - args = ["docker", "run", "-d", "--name", name, "--network", network] - if user: - args += ["-u", user] - for k, v in env.items(): - args += ["-e", f"{k}={v}"] - for host, container, readonly in mounts: - args += ["-v", f"{host}:{container}:{'ro' if readonly else 'rw'}"] - args.append(image) - if command: - args += command - return Container(name=name, id=_output(args).strip()) - - def logs(self, name: str) -> str: - return _output(["docker", "logs", name]) - - def inspect(self, name: str) -> dict: - return json.loads(_output(["docker", "inspect", name]))[0] - - def wait_for_log(self, name: str, needle: str, timeout: float) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if needle in self.logs(name): - return - self.assert_running(name) - time.sleep(2) - raise TimeoutError(f"Timeout waiting for {needle!r} in {name}") - - def assert_running(self, name: str) -> None: - state = self.inspect(name).get("State", {}) - if not state.get("Running", False): - raise RuntimeError( - f"Container {name} exited (code={state.get('ExitCode', -1)}, error={state.get('Error', '')})" - ) - - def wait_exited(self, name: str, timeout: float) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not self.inspect(name).get("State", {}).get("Running", False): - return - time.sleep(1) - raise TimeoutError(f"Timeout waiting for {name} to exit") diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index 54be63d6f..965ea7735 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -6,23 +6,112 @@ import re import secrets import shutil -import subprocess import time from collections.abc import Callable from fnmatch import fnmatch from pathlib import Path +import docker as docker_py + from .bridge import BridgeClient from .config import Target -from .docker import Docker + logger = logging.getLogger(__name__) +_docker = docker_py.from_env() + + +def _container(name): + return _docker.containers.get(name) + + +def _container_logs(name): + try: + return _container(name).logs().decode("utf-8", errors="replace") + except docker_py.errors.NotFound: + return "" + + +def _remove_container(name): + try: + _container(name).remove(force=True) + except docker_py.errors.NotFound: + pass + + +def _ensure_network(name): + _remove_network(name) + _docker.networks.create(name, check_duplicate=True) + + +def _remove_network(name): + try: + _docker.networks.get(name).remove() + except docker_py.errors.NotFound: + pass + + +def _ensure_volume(name): + _docker.volumes.create(name) + + +def _remove_volume(name): + try: + _docker.volumes.get(name).remove() + except docker_py.errors.NotFound: + pass + + +def _run_container(name, image, network, env, mounts, command=None, user=None): + volumes = {} + for host, container_path, readonly in mounts: + volumes[str(host)] = {"bind": container_path, "mode": "ro" if readonly else "rw"} + return _docker.containers.run( + image, detach=True, name=name, network=network, + environment=dict(env), volumes=volumes, command=command, user=user, + ) + + +def _assert_running(name): + c = _container(name) + c.reload() + state = c.attrs.get("State", {}) + if not state.get("Running", False): + raise RuntimeError( + f"Container {name} exited (code={state.get('ExitCode', -1)}, error={state.get('Error', '')})" + ) + + +def _inspect_container(name): + return _container(name).attrs + + +def _wait_for_log(name, needle, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if needle in _container_logs(name): + return + _assert_running(name) + time.sleep(2) + raise TimeoutError(f"Timeout waiting for {needle!r} in {name}") + + +def _wait_exited(name, timeout): + c = _container(name) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + c.reload() + if not c.attrs.get("State", {}).get("Running", False): + return + time.sleep(1) + raise TimeoutError(f"Timeout waiting for {name} to exit") + + PHASES: dict[str, Callable] = {} def _reg(name: str) -> Callable: - """Decorator: register a function in PHASES under the given name.""" def wrapper(fn: Callable) -> Callable: PHASES[name] = fn return fn @@ -73,7 +162,6 @@ def run_case( srv_name = f"amp-s-{secrets.token_hex(4)}"[:63] cli_name = f"amp-c-{secrets.token_hex(4)}"[:63] token = secrets.token_hex(16) - docker = Docker() ctx = dict(locals()) for d in (server_dir, game_dir, cache_dir): @@ -99,7 +187,7 @@ def run_case( _prepare_server(ctx, target, settings) _seed_cache(ctx) - docker.create_network(net_name) + _ensure_network(net_name) flow = scenario.get("flow", []) if not flow: @@ -130,7 +218,7 @@ def run_case( finally: for name in [cli_name, srv_name]: try: - logs = docker.logs(name) + logs = _container_logs(name) if logs: (case_dir / f"{name}.log").write_text( logs, encoding="utf-8", errors="replace" @@ -138,17 +226,16 @@ def run_case( except Exception: pass try: - docker.remove_container(name) + _remove_container(name) except Exception: logger.warning("Failed to remove container %s", name) - docker.remove_network(net_name) + _remove_network(net_name) # === infrastructure (not flow phases) === def _prepare_server(ctx, target, settings): - """Write server config and test files to server_dir BEFORE launch.""" srv_dir = ctx["server_dir"] (srv_dir / "mods").mkdir(parents=True, exist_ok=True) shutil.copy2(ctx["artifact"], srv_dir / "mods" / "automodpack.jar") @@ -172,45 +259,17 @@ def _prepare_server(ctx, target, settings): def _seed_cache(ctx): - """Pre-populate HMC cache from previous run (client side only).""" cache_seed = (ctx["out_dir"].parent / ".hmc-cache").resolve() if cache_seed.exists() and cache_seed != ctx["cache_dir"]: - try: - subprocess.run( - [ - "cp", - "-ra", - "--reflink=auto", - f"{cache_seed}/.", - f"{ctx['cache_dir']}/", - ], - check=True, - capture_output=True, - ) - except subprocess.CalledProcessError: - shutil.copytree( - cache_seed, - ctx["cache_dir"], - copy_function=shutil.copy2, - dirs_exist_ok=True, - ) + shutil.copytree( + cache_seed, + ctx["cache_dir"], + copy_function=shutil.copy2, + dirs_exist_ok=True, + ) def _launch_server(ctx, target, scenario, settings): - """Start the Minecraft server container. - - Server type (forge/fabric/etc) comes from: - 1. topology.server.type in the scenario YAML - 2. serverTypes.{loader} in settings.yaml - 3. Auto-detected by itzg/minecraft-server if neither set. - - Server env vars are merged from (lower wins): - - settings.yaml → server.env (defaults) - - scenario YAML → topology.server.env (overrides) - - Server files/libraries are cached in a Docker volume - (amp-server-cache-{target.id}) that persists between runs. - """ topo = scenario.get("topology", {}).get("server", {}) srv_type = topo.get("type") or settings.get("serverTypes", {}).get(target.loader) if not srv_type: @@ -256,8 +315,8 @@ def _launch_server(ctx, target, scenario, settings): if sc.get("enabled", True): vol = f"{sc.get('volumePrefix', 'amp-server-cache')}-{target.id}" if sc.get("clean", False): - ctx["docker"].remove_volume(vol) - ctx["docker"].ensure_volume(vol) + _remove_volume(vol) + _ensure_volume(vol) mounts = [(vol, "/data", False)] for sub in ("mods", "automodpack"): (ctx["server_dir"] / sub).mkdir(parents=True, exist_ok=True) @@ -270,17 +329,12 @@ def _launch_server(ctx, target, scenario, settings): ) if ":" not in img: img = f"{img}:{str(settings.get('images', {}).get('serverTagTemplate', 'java{java}')).format(java=target.java)}" - ctx["docker"].run_detached( + _run_container( name=ctx["srv_name"], image=img, network=ctx["net_name"], env=env, mounts=mounts ) def _launch_client(ctx, target, client_image): - """Start a fresh client container. - - Client uses an HMC cache directory (hmc-cache/) mounted at /work/hmc-cache. - This cache is seeded from the previous run via _seed_cache above. - """ game_dir = ctx["game_dir"] (game_dir / "mods").mkdir(parents=True, exist_ok=True) shutil.copy2(ctx["artifact"], game_dir / "mods" / "automodpack.jar") @@ -289,7 +343,7 @@ def _launch_client(ctx, target, client_image): (game_dir / "config" / "fml.toml").write_text( 'disableConfigWatcher = false\nearlyWindowControl = false\nmaxThreads = -1\nversionCheck = true\ndefaultConfigPath = "defaultconfigs"\ndisableOptimizedDFU = true\nearlyWindowProvider = "fmlearlywindow"\nearlyWindowWidth = 854\nearlyWindowHeight = 480\nearlyWindowMaximized = false\ndebugOpenGl = false\nearlyWindowFBScale = 1\nearlyWindowSkipGLVersions = []\nearlyWindowSquir = false\nearlyLoadingScreenTheme = ""\ndependencyOverrides = {}\n' ) - ctx["docker"].run_detached( + _run_container( name=ctx["cli_name"], image=client_image, network=ctx["net_name"], @@ -314,22 +368,21 @@ def _launch_client(ctx, target, client_image): user=f"{_uid()}:{_gid()}", ) time.sleep(1) - ctx["docker"].assert_running(ctx["cli_name"]) + _assert_running(ctx["cli_name"]) def _wait_server(ctx, target, scenario, settings): to = scenario.get("timeouts", {}) or settings.get("timeouts", {}) - ctx["docker"].wait_for_log( + _wait_for_log( ctx["srv_name"], "Done (", timeout=float(to.get("serverStartSeconds", 180)) ) -# === flow phases — each does exactly ONE thing === +# === flow phases === @_reg("wait_bridge") def _phase_wait_bridge(ctx): - """Poll bridge-state.json until it exists, then ping the bridge.""" if "bridge" in ctx: return ctx["bridge"] = BridgeClient(ctx["game_dir"], ctx["token"]) @@ -337,9 +390,9 @@ def _phase_wait_bridge(ctx): dl = time.monotonic() + to while time.monotonic() < dl: try: - ctx["docker"].assert_running(ctx["cli_name"]) + _assert_running(ctx["cli_name"]) except RuntimeError as e: - logs = ctx["docker"].logs(ctx["cli_name"]) + logs = _container_logs(ctx["cli_name"]) raise TimeoutError( f"Client exited before bridge: {e}\n--- logs ---\n{logs[-2000:]}" ) @@ -355,7 +408,6 @@ def _phase_wait_bridge(ctx): @_reg("click_continue") def _phase_click_continue(ctx): - """Dismiss any interstitial screen by clicking 'Continue' until TitleScreen appears.""" bridge = ctx["bridge"] dl = time.monotonic() + 30 while time.monotonic() < dl: @@ -380,17 +432,10 @@ def _phase_click_continue(ctx): @_reg("read_fingerprint") def _phase_read_fingerprint(ctx): - """Read TLS certificate fingerprint from server container logs. - - Polls for up to serverStartSeconds (default 180s) because the server - starts in parallel with this phase. The fingerprint line appears - early in startup — usually within seconds — but we keep waiting - for slow starts. - """ to = float(ctx["scenario"].get("timeouts", {}).get("serverStartSeconds", 180)) dl = time.monotonic() + to while time.monotonic() < dl: - logs = ctx["docker"].logs(ctx["srv_name"]) + logs = _container_logs(ctx["srv_name"]) for line in logs.splitlines(): m = re.search( r"(?:certificate\s+)?fingerprint[:\s]+([0-9A-Fa-f:]+)", line, re.IGNORECASE @@ -404,27 +449,23 @@ def _phase_read_fingerprint(ctx): @_reg("connect") def _phase_connect(ctx): - """Connect the client to the server, retrying if connection fails or sticks.""" bridge = ctx["bridge"] host = ctx["srv_name"] deadline = time.monotonic() + 90 - # Both Yarn and MojMap class-name checks (Yarn: class_397=ConnectScreen, class_442=TitleScreen) _TITLE = ("TitleScreen", "class_442") _CONNECT = ("ConnectScreen", "class_397") while time.monotonic() < deadline: bridge.request("connect", host=host, port=25565) - # Poll up to 15s: TitleScreen → failure (retry); not ConnectScreen → success; stuck on ConnectScreen → retry poll_dl = time.monotonic() + 15 while time.monotonic() < poll_dl: screen = str(bridge.request("get_screen").get("screenClass") or "") if any(n in screen for n in _TITLE): - break # connection failed → retry + break if not any(n in screen for n in _CONNECT): - return # no longer connecting → success (FingerprintScreen, DangerScreen, etc.) + return time.sleep(0.5) - # Cancel the stuck connection by reopening the title screen before retrying bridge.request("set_screen") time.sleep(1) raise RuntimeError("Could not connect after multiple attempts") @@ -432,7 +473,6 @@ def _phase_connect(ctx): @_reg("wait_fingerprint") def _phase_wait_fingerprint(ctx): - """Wait for the mod's FingerprintVerificationScreen to appear.""" fp = ctx.get("fingerprint") if not fp: raise RuntimeError("No fingerprint — run read_fingerprint phase first") @@ -449,7 +489,6 @@ def _phase_wait_fingerprint(ctx): @_reg("accept_fingerprint") def _phase_accept_fingerprint(ctx): - """Type the fingerprint into the EditBox and click Verify (real user flow).""" fp = ctx.get("fingerprint") if not fp: raise RuntimeError("No fingerprint — run read_fingerprint phase first") @@ -471,7 +510,6 @@ def _phase_accept_fingerprint(ctx): @_reg("skip_fingerprint") def _phase_skip_fingerprint(ctx): - """Skip fingerprint verification (for versions without EditBox).""" bridge = ctx["bridge"] bridge.request("click", selector={"text": "Skip"}) _await( @@ -502,7 +540,6 @@ def _phase_skip_fingerprint(ctx): @_reg("wait_danger") def _phase_wait_danger(ctx): - """Wait for DangerScreen to appear.""" bridge = ctx["bridge"] _await( lambda: ( @@ -516,7 +553,6 @@ def _phase_wait_danger(ctx): @_reg("click_confirm") def _phase_click_confirm(ctx): - """Click the last active Button on the current screen (Confirm on DangerScreen).""" bridge = ctx["bridge"] dl = time.monotonic() + 5 while time.monotonic() < dl: @@ -533,7 +569,6 @@ def _phase_click_confirm(ctx): @_reg("wait_download") def _phase_wait_download(ctx): - """Wait for the modpack download marker file to appear.""" marker = ( ctx["game_dir"] / "automodpack" @@ -553,7 +588,6 @@ def _phase_wait_download(ctx): @_reg("verify_files") def _phase_verify_files(ctx): - """Check that all expected serverFiles exist in the synced modpack.""" mp_root = ctx["game_dir"] / "automodpack" / "modpacks" / ctx["modpack_name"] dl = time.monotonic() + 120 while time.monotonic() < dl: @@ -568,7 +602,6 @@ def _phase_verify_files(ctx): @_reg("verify_mods") def _phase_verify_mods(ctx): - """Check that all expected mod patterns match at least one installed jar.""" if not ctx["expected_mods"]: return mp_root = ctx["game_dir"] / "automodpack" / "modpacks" / ctx["modpack_name"] @@ -588,7 +621,6 @@ def _phase_verify_mods(ctx): @_reg("click_restart") def _phase_click_restart(ctx): - """Wait 20s for RestartScreen; if shown, click Restart. Otherwise continue.""" bridge = ctx["bridge"] dl = time.monotonic() + 20 while time.monotonic() < dl: @@ -607,16 +639,15 @@ def _phase_click_restart(ctx): break except RuntimeError: pass - ctx["docker"].wait_exited(ctx["cli_name"], timeout=90) + _wait_exited(ctx["cli_name"], timeout=90) return time.sleep(0.5) @_reg("quit") def _phase_quit(ctx): - """Disconnect and quit the game if still running.""" try: - state = ctx["docker"].inspect(ctx["cli_name"]).get("State", {}) + state = _inspect_container(ctx["cli_name"]).get("State", {}) if state.get("Running", False): ctx["bridge"].request("quit") except (RuntimeError, TimeoutError): @@ -625,20 +656,17 @@ def _phase_quit(ctx): @_reg("launch_server") def _phase_launch_server(ctx): - """Start the server container.""" _launch_server(ctx, ctx["target"], ctx["scenario"], ctx["settings"]) @_reg("wait_server") def _phase_wait_server(ctx): - """Wait for 'Done (' in server logs.""" _wait_server(ctx, ctx["target"], ctx["scenario"], ctx["settings"]) @_reg("launch_client") def _phase_launch_client(ctx): - """Start a fresh client container (for rejoin phase).""" - ctx["docker"].remove_container(ctx["cli_name"]) + _remove_container(ctx["cli_name"]) if "bridge" in ctx: del ctx["bridge"] _launch_client(ctx, ctx["target"], ctx["client_image"]) @@ -646,7 +674,6 @@ def _phase_launch_client(ctx): @_reg("wait_join") def _phase_wait_join(ctx): - """Wait for null screenClass indicating the player is in-game.""" bridge = ctx["bridge"] to = float(ctx["scenario"].get("timeouts", {}).get("rejoinSeconds", 180)) _await( diff --git a/autotester/pyproject.toml b/autotester/pyproject.toml index 84eb85ddd..5c32cf748 100644 --- a/autotester/pyproject.toml +++ b/autotester/pyproject.toml @@ -8,6 +8,7 @@ version = "0.1.0" description = "Docker-first in-game integration tests for AutoModpack" requires-python = ">=3.11" dependencies = [ + "docker>=7.1.0", "PyYAML>=6.0.1", ] diff --git a/autotester/uv.lock b/autotester/uv.lock index 5bc665836..5bd42de43 100644 --- a/autotester/uv.lock +++ b/autotester/uv.lock @@ -7,11 +7,155 @@ name = "automodpack-autotest" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "docker" }, { name = "pyyaml" }, ] [package.metadata] -requires-dist = [{ name = "pyyaml", specifier = ">=6.0.1" }] +requires-dist = [ + { name = "docker", specifier = ">=7.1.0" }, + { name = "pyyaml", specifier = ">=6.0.1" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] [[package]] name = "pyyaml" @@ -67,3 +211,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From b93bb1f52778f5fd2f9cfbd5f46d66143c3e31df Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 18:26:56 +0200 Subject: [PATCH 04/44] piss --- autotester/README.md | 4 -- build.fabric.gradle.kts | 11 +---- build.forge.gradle.kts | 1 + build.neoforge.gradle.kts | 17 ++------ .../automodpack_loader_core/Preload.java | 6 --- loader/loader-neoforge.gradle.kts | 22 ++-------- .../EarlyModLocator.java | 27 +----------- .../LazyModLocator.java | 41 ++++++++++++++++++- .../resources/META-INF/jarjar/metadata.json | 12 ------ ...ed.neoforgespi.locating.IDependencyLocator | 1 + 10 files changed, 49 insertions(+), 93 deletions(-) delete mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json create mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator diff --git a/autotester/README.md b/autotester/README.md index a68fa1dd8..eae11cf0b 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -410,11 +410,7 @@ topology: dependencies: true ``` -<<<<<<< HEAD -Then run: `uv run autotester run --scenario my-test` -======= Then run: `uv --project autotester run autotester run --scenario my-test` ->>>>>>> 4d2f314c (autotester: Docker-based in-game integration test framework) ## CI (GitHub Actions) diff --git a/build.fabric.gradle.kts b/build.fabric.gradle.kts index deee58a10..710e92d7e 100644 --- a/build.fabric.gradle.kts +++ b/build.fabric.gradle.kts @@ -66,6 +66,7 @@ java { } else { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + toolchain.languageVersion.set(JavaLanguageVersion.of(17)) } withSourcesJar() } @@ -75,17 +76,7 @@ sourceSets.main { } tasks { - named("sourcesJar") { - dependsOn("stonecutterGenerate") - } - named("compileJava") { - dependsOn("stonecutterGenerate") - } - named("compileKotlin") { - dependsOn("stonecutterGenerate") - } processResources { - dependsOn("stonecutterGenerate") exclude("**/neoforge.mods.toml", "**/mods.toml", "**/accesstransformer*.cfg") if (fabric.isUnobf) { exclude("**/automodpack.accesswidener") diff --git a/build.forge.gradle.kts b/build.forge.gradle.kts index 4124762b9..6fd228ea9 100644 --- a/build.forge.gradle.kts +++ b/build.forge.gradle.kts @@ -93,6 +93,7 @@ java { } else { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + toolchain.languageVersion.set(JavaLanguageVersion.of(17)) } withSourcesJar() } diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts index e5fcda3b1..9e0d6ff87 100644 --- a/build.neoforge.gradle.kts +++ b/build.neoforge.gradle.kts @@ -32,10 +32,7 @@ dependencies { tasks { processResources { - exclude("**/fabric.mod.json", "**/automodpack*.accesswidener") - if (sc.current.parsed >= "1.21") { - exclude("**/mods.toml") - } + exclude("**/fabric.mod.json", "**/automodpack*.accesswidener", "**/mods.toml") if (sc.current.parsed >= "1.21.9") { exclude("**/pack.mcmeta") rename("new-pack.mcmeta", "pack.mcmeta") @@ -54,18 +51,10 @@ java { sourceCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_25 toolchain.languageVersion.set(JavaLanguageVersion.of(25)) - } else if (sc.current.parsed >= "1.21") { - withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } else if (sc.current.parsed >= "1.20.5") { - withSourcesJar() sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 - } else { - withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } + withSourcesJar() } diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java index 475747723..37d49ac1a 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java @@ -264,12 +264,6 @@ private void bootstrapAutotestClientHooks() { if (LOADER_MANAGER.getEnvironmentType() != LoaderManagerService.EnvironmentType.CLIENT) { return; } - try { - Class screenImplClass = Class.forName("pl.skidam.automodpack.client.ScreenImpl"); - ScreenManager.INSTANCE = (pl.skidam.automodpack_loader_core.screen.ScreenService) screenImplClass.getDeclaredConstructor().newInstance(); - } catch (Throwable e) { - LOGGER.warn("Failed to bootstrap AutoModpack ScreenImpl during prelaunch", e); - } try { Class bridgeClass = Class.forName("pl.skidam.automodpack.client.autotest.AutoTestBridge"); bridgeClass.getMethod("startIfEnabled").invoke(null); diff --git a/loader/loader-neoforge.gradle.kts b/loader/loader-neoforge.gradle.kts index 50cb8de79..07819bcba 100644 --- a/loader/loader-neoforge.gradle.kts +++ b/loader/loader-neoforge.gradle.kts @@ -81,26 +81,10 @@ tasks.named("shadowJar") { mergeServiceFiles() } - -val moduleName = project.name.removePrefix("loader-") java { - when { - moduleName == "neoforge-fml10" -> { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - } - moduleName == "neoforge-fml4" -> { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - } - else -> { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain.languageVersion.set(JavaLanguageVersion.of(17)) - } - } + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) withSourcesJar() } diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java index 86aa9704f..eb862e59b 100644 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java +++ b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java @@ -1,11 +1,6 @@ package pl.skidam.automodpack_loader_core_neoforge; -import cpw.mods.modlauncher.api.LamdbaExceptionUtils; - -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.util.*; import java.util.stream.Stream; import net.neoforged.fml.loading.moddiscovery.AbstractJarFileModLocator; @@ -16,7 +11,6 @@ @SuppressWarnings("unused") public class EarlyModLocator extends AbstractJarFileModLocator { - private static final String EMBEDDED_MOD_PATH = "META-INF/jarjar/automodpack-mod.jar"; @Override public void initArguments(Map arguments) {} @@ -35,25 +29,6 @@ public Stream scanCandidates() { new Preload(); progress.complete(); - return Stream.concat( - Stream.of(getEmbeddedModPath()), - ModpackLoader.modsToLoad.stream() - ).distinct(); - } - - private Path getEmbeddedModPath() { - return LamdbaExceptionUtils.uncheck(() -> { - Path extractedDir = Files.createTempDirectory("automodpack-fml2-embedded-"); - Path extractedJar = extractedDir.resolve("automodpack-mod.jar"); - try (InputStream stream = EarlyModLocator.class.getClassLoader().getResourceAsStream(EMBEDDED_MOD_PATH)) { - if (stream == null) { - throw new IllegalStateException("Missing embedded mod resource: " + EMBEDDED_MOD_PATH); - } - Files.copy(stream, extractedJar, StandardCopyOption.REPLACE_EXISTING); - } - extractedJar.toFile().deleteOnExit(); - extractedDir.toFile().deleteOnExit(); - return extractedJar; - }); + return ModpackLoader.modsToLoad.stream(); } } diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java index 9eb737a09..2e2382f25 100644 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java +++ b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java @@ -1,23 +1,60 @@ package pl.skidam.automodpack_loader_core_neoforge; +import com.google.common.collect.ImmutableMap; import net.neoforged.fml.loading.moddiscovery.AbstractJarFileDependencyLocator; import net.neoforged.neoforgespi.locating.IModFile; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import static cpw.mods.modlauncher.api.LamdbaExceptionUtils.uncheck; + @SuppressWarnings("unused") public class LazyModLocator extends AbstractJarFileDependencyLocator { @Override public List scanMods(Iterable loadedMods) { - return List.of(); + var list = new ArrayList(); + try { + list.add(getMainMod()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return list; } @Override public String name() { - return "automodpack"; + return null; } @Override public void initArguments(Map arguments) { } + + // Code based on connector's https://github.com/Sinytra/Connector/blob/0514fec8f189b88c5cec54dc5632fbcee13d56dc/src/main/java/dev/su5ed/sinytra/connector/locator/EmbeddedDependencies.java#L88 + private IModFile getMainMod() throws IOException, URISyntaxException { + final Path SELF_PATH = uncheck(() -> { + URL jarLocation = LazyModLocator.class.getProtectionDomain().getCodeSource().getLocation(); + return Path.of(jarLocation.toURI()); + }); + + final String depName = "META-INF/jarjar/automodpack-mod.jar"; + + final Path pathInModFile = SELF_PATH.resolve(depName); + final URI filePathUri = new URI("jij:" + pathInModFile.toAbsolutePath().toUri().getRawSchemeSpecificPart()).normalize(); + final Map outerFsArgs = ImmutableMap.of("packagePath", pathInModFile); + final FileSystem zipFS = FileSystems.newFileSystem(filePathUri, outerFsArgs); + + final Path modPath = zipFS.getPath("/"); + var mod = createMod(modPath); + return mod.file(); + } } diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json b/loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json deleted file mode 100644 index 975276778..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/jarjar/metadata.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "jars": [ - { - "identifier": { - "group": "pl.skidam", - "artifact": "automodpack" - }, - "path": "META-INF/jarjar/automodpack-mod.jar", - "isObfuscated": false - } - ] -} diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator new file mode 100644 index 000000000..5f862a77e --- /dev/null +++ b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator @@ -0,0 +1 @@ +pl.skidam.automodpack_loader_core.LazyModLocator From 7b8bf9dd036c8184d762eba29ccf8d979faf43b5 Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 18:31:08 +0200 Subject: [PATCH 05/44] piss --- autotester/scenarios/sync.yaml | 2 +- build.fabric.gradle.kts | 4 ---- .../services/net.neoforged.neoforgespi.locating.IModLocator | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index 89eb98767..c9c71fe5a 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -29,7 +29,7 @@ # quit Bridge: quit game → wait for client exit. # wait_join Poll null screenClass (player in-game). # -# ── serverFiles (replaces old "assertions") ───────────────────────────── +# ── serverFiles ───────────────────────────────────────────────────────── # Describes files the SERVER hosts in its modpack; the client downloads # and syncs them. Everything is under the server's host-modpack/main/ dir. # diff --git a/build.fabric.gradle.kts b/build.fabric.gradle.kts index 710e92d7e..3e458a666 100644 --- a/build.fabric.gradle.kts +++ b/build.fabric.gradle.kts @@ -71,10 +71,6 @@ java { withSourcesJar() } -sourceSets.main { - java.setSrcDirs(listOf(layout.buildDirectory.dir("generated/stonecutter/main/java"))) -} - tasks { processResources { exclude("**/neoforge.mods.toml", "**/mods.toml", "**/accesstransformer*.cfg") diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator index 25faad979..f5484f137 100644 --- a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator +++ b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator @@ -1 +1 @@ -pl.skidam.automodpack_loader_core.EarlyModLocator \ No newline at end of file +pl.skidam.automodpack_loader_core.EarlyModLocator From 883927da3ace3542fea13139a26b089c6aae14b9 Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 18:36:43 +0200 Subject: [PATCH 06/44] reuse build workflow --- .github/workflows/ingame-tests.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ingame-tests.yml b/.github/workflows/ingame-tests.yml index 38595ea7f..48f9da9a1 100644 --- a/.github/workflows/ingame-tests.yml +++ b/.github/workflows/ingame-tests.yml @@ -38,18 +38,8 @@ jobs: build: needs: [prepare] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install uv - uses: astral-sh/setup-uv@v7 - - name: Build AutoModpack - run: ./gradlew build - - uses: actions/upload-artifact@v6 - with: - name: merged-jars - path: merged/ - if-no-files-found: error + uses: ./.github/workflows/build.yml + secrets: inherit ingame: needs: [build, prepare] @@ -63,7 +53,7 @@ jobs: uses: astral-sh/setup-uv@v7 - uses: actions/download-artifact@v6 with: - name: merged-jars + name: build-artifacts path: merged/ - name: Build autotest client image run: uv --project autotester run autotester build-images From a0d2f0432d161bc94ec43c15b938ed415004bfda Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 20 May 2026 18:51:39 +0200 Subject: [PATCH 07/44] shit --- .../pl/skidam/automodpack/mixin/core/FabricLoginMixin.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java index 3c81b64d7..4c31522cc 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java @@ -1,7 +1,7 @@ package pl.skidam.automodpack.mixin.core; import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Pseudo; import org.spongepowered.asm.mixin.injection.At; @@ -22,7 +22,7 @@ private void dontRemoveAutoModpackChannels(ClientboundCustomQueryPacket packet, /*? if <1.20.2 {*/ /*Identifier id = packet.getIdentifier(); *//*?} else {*/ - ResourceLocation id = packet.payload().id(); + Identifier id = packet.payload().id(); /*?}*/ // Cancel if it's one of our channels if (LoginNetworkingIDs.getByKey(id) != null) { From 133640ee88219247adf9a45f31af811236021732 Mon Sep 17 00:00:00 2001 From: skidam Date: Thu, 21 May 2026 10:45:20 +0200 Subject: [PATCH 08/44] aaaaaa --- build.neoforge.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts index 9e0d6ff87..ad8f17ee9 100644 --- a/build.neoforge.gradle.kts +++ b/build.neoforge.gradle.kts @@ -51,7 +51,7 @@ java { sourceCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_25 toolchain.languageVersion.set(JavaLanguageVersion.of(25)) - } else if (sc.current.parsed >= "1.20.5") { + } else { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 toolchain.languageVersion.set(JavaLanguageVersion.of(21)) From 0eeaaa60ed7026fdce3012879a7746677fa340b1 Mon Sep 17 00:00:00 2001 From: skidam Date: Thu, 21 May 2026 10:46:37 +0200 Subject: [PATCH 09/44] bbb --- autotester/docker/client/run-headlessmc-client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 421414754..49a4f7e60 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -24,7 +24,7 @@ case "$java_version" in export JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64" ;; 25) - export JAVA_HOME="/usr/lib/jvm/java-25-temurin" + export JAVA_HOME="/usr/lib/jvm/java-25-openjdk-amd64" ;; *) echo "ERROR: unsupported Java version: $java_version" >&2 From 4d52d8af399df6d9d9993a46ebe563010a992898 Mon Sep 17 00:00:00 2001 From: skidam Date: Thu, 21 May 2026 15:31:44 +0200 Subject: [PATCH 10/44] drop problematic and not widely used 1.20.4 --- autotester/targets.yaml | 2 - buildSrc/src/main/kotlin/ModuleUtils.kt | 1 - gradle.properties | 2 +- loader/neoforge/fml2/gradle.properties | 1 - .../EarlyModLocator.java | 34 ---------- .../LazyModLocator.java | 60 ----------------- .../loader/LoaderManager.java | 67 ------------------- .../mods/ModpackLoader.java | 40 ----------- .../src/main/resources/META-INF/mods.toml | 14 ---- ...ed.neoforgespi.locating.IDependencyLocator | 1 - ...neoforged.neoforgespi.locating.IModLocator | 1 - settings.gradle.kts | 3 +- stonecutter.properties.toml | 12 ---- 13 files changed, 2 insertions(+), 236 deletions(-) delete mode 100644 loader/neoforge/fml2/gradle.properties delete mode 100644 loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java delete mode 100644 loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java delete mode 100644 loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java delete mode 100644 loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java delete mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/mods.toml delete mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator delete mode 100644 loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator diff --git a/autotester/targets.yaml b/autotester/targets.yaml index 557be7f3a..7f884be3d 100644 --- a/autotester/targets.yaml +++ b/autotester/targets.yaml @@ -17,8 +17,6 @@ targets: - { id: "1.21.4-neoforge", minecraft: "1.21.4", loader: "neoforge", java: 21, neoforgeVersion: "21.4.157" } - { id: "1.21.1-fabric", minecraft: "1.21.1", loader: "fabric", java: 21, fabricLoader: "0.17.3" } - { id: "1.21.1-neoforge", minecraft: "1.21.1", loader: "neoforge", java: 21, neoforgeVersion: "21.1.230" } - - { id: "1.20.4-fabric", minecraft: "1.20.4", loader: "fabric", java: 17, fabricLoader: "0.17.3" } - - { id: "1.20.4-neoforge", minecraft: "1.20.4", loader: "neoforge", java: 17, neoforgeVersion: "20.4.248" } - { id: "1.20.1-fabric", minecraft: "1.20.1", loader: "fabric", java: 17, fabricLoader: "0.17.3" } - { id: "1.20.1-forge", minecraft: "1.20.1", loader: "forge", java: 17, forgeVersion: "47.3.0" } - { id: "1.19.2-fabric", minecraft: "1.19.2", loader: "fabric", java: 17, fabricLoader: "0.17.3" } diff --git a/buildSrc/src/main/kotlin/ModuleUtils.kt b/buildSrc/src/main/kotlin/ModuleUtils.kt index 8f5784a28..4ad5c0e6f 100644 --- a/buildSrc/src/main/kotlin/ModuleUtils.kt +++ b/buildSrc/src/main/kotlin/ModuleUtils.kt @@ -10,7 +10,6 @@ fun getLoaderModuleName(name: String): String { return when { name.contains("fabric") -> "fabric-core" name.contains("neoforge") -> when (mcVersion) { - "1.20.4", "1.20.1", "1.19.2", "1.18.2" -> "neoforge-fml2" "1.21.8", "1.21.5", "1.21.4", "1.21.1" -> "neoforge-fml4" "1.21.11", "1.21.10", "26.1" -> "neoforge-fml10" else -> error("Unknown neoforge loader module for Minecraft version: $mcVersion") diff --git a/gradle.properties b/gradle.properties index c68b93b4a..adb375245 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ org.gradle.caching = true org.gradle.configuration-cache.problems = warn org.gradle.daemon = true -core_modules = core, fabric-core, fabric-15, fabric-16, forge-fml40, forge-fml47, neoforge-fml2, neoforge-fml4, neoforge-fml10 +core_modules = core, fabric-core, fabric-15, fabric-16, forge-fml40, forge-fml47, neoforge-fml4, neoforge-fml10 mod.id = automodpack_mod mod_name = AutoModpack diff --git a/loader/neoforge/fml2/gradle.properties b/loader/neoforge/fml2/gradle.properties deleted file mode 100644 index 5cf92ca53..000000000 --- a/loader/neoforge/fml2/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -deps.neoforge=20.4.248 \ No newline at end of file diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java deleted file mode 100644 index eb862e59b..000000000 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java +++ /dev/null @@ -1,34 +0,0 @@ -package pl.skidam.automodpack_loader_core_neoforge; - -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; -import net.neoforged.fml.loading.moddiscovery.AbstractJarFileModLocator; -import net.neoforged.fml.loading.progress.ProgressMeter; -import net.neoforged.fml.loading.progress.StartupNotificationManager; -import pl.skidam.automodpack_loader_core.Preload; -import pl.skidam.automodpack_loader_core_neoforge.mods.ModpackLoader; - -@SuppressWarnings("unused") -public class EarlyModLocator extends AbstractJarFileModLocator { - - @Override - public void initArguments(Map arguments) {} - - @Override - public String name() { - return "automodpack"; - } - - @Override - public Stream scanCandidates() { - ProgressMeter progress = StartupNotificationManager.prependProgressBar( - "[Automodpack] Preload", - 0 - ); - new Preload(); - progress.complete(); - - return ModpackLoader.modsToLoad.stream(); - } -} diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java deleted file mode 100644 index 2e2382f25..000000000 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java +++ /dev/null @@ -1,60 +0,0 @@ -package pl.skidam.automodpack_loader_core_neoforge; - -import com.google.common.collect.ImmutableMap; -import net.neoforged.fml.loading.moddiscovery.AbstractJarFileDependencyLocator; -import net.neoforged.neoforgespi.locating.IModFile; - -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static cpw.mods.modlauncher.api.LamdbaExceptionUtils.uncheck; - -@SuppressWarnings("unused") -public class LazyModLocator extends AbstractJarFileDependencyLocator { - @Override - public List scanMods(Iterable loadedMods) { - var list = new ArrayList(); - try { - list.add(getMainMod()); - } catch (Exception e) { - throw new RuntimeException(e); - } - - return list; - } - - @Override - public String name() { - return null; - } - - @Override - public void initArguments(Map arguments) { } - - // Code based on connector's https://github.com/Sinytra/Connector/blob/0514fec8f189b88c5cec54dc5632fbcee13d56dc/src/main/java/dev/su5ed/sinytra/connector/locator/EmbeddedDependencies.java#L88 - private IModFile getMainMod() throws IOException, URISyntaxException { - final Path SELF_PATH = uncheck(() -> { - URL jarLocation = LazyModLocator.class.getProtectionDomain().getCodeSource().getLocation(); - return Path.of(jarLocation.toURI()); - }); - - final String depName = "META-INF/jarjar/automodpack-mod.jar"; - - final Path pathInModFile = SELF_PATH.resolve(depName); - final URI filePathUri = new URI("jij:" + pathInModFile.toAbsolutePath().toUri().getRawSchemeSpecificPart()).normalize(); - final Map outerFsArgs = ImmutableMap.of("packagePath", pathInModFile); - final FileSystem zipFS = FileSystems.newFileSystem(filePathUri, outerFsArgs); - - final Path modPath = zipFS.getPath("/"); - var mod = createMod(modPath); - return mod.file(); - } -} diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java deleted file mode 100644 index 13083e566..000000000 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java +++ /dev/null @@ -1,67 +0,0 @@ -package pl.skidam.automodpack_loader_core_neoforge.loader; - -import net.neoforged.api.distmarker.Dist; -import net.neoforged.fml.loading.FMLLoader; -import net.neoforged.fml.loading.LoadingModList; -import net.neoforged.fml.loading.moddiscovery.ModInfo; -import pl.skidam.automodpack_core.loader.LoaderManagerService; - -import static pl.skidam.automodpack_core.Constants.*; - -@SuppressWarnings("unused") -public class LoaderManager implements LoaderManagerService { - - @Override - public ModPlatform getPlatformType() { - return ModPlatform.NEOFORGE; - } - - @Override - public boolean isModLoaded(String modId) { - LoadingModList loadingModList; - try { - loadingModList= FMLLoader.getLoadingModList(); - } catch (IllegalStateException e) { - return false; - } - return loadingModList.getModFileById(modId) != null; - } - - @Override - public String getLoaderVersion() { - return FMLLoader.versionInfo().neoForgeVersion(); - } - - @Override - public EnvironmentType getEnvironmentType() { - if (FMLLoader.getDist() == Dist.CLIENT) { - return EnvironmentType.CLIENT; - } else { - return EnvironmentType.SERVER; - } - } - - @Override - public String getModVersion(String modId) { - if (preload) { - if (modId.equals("minecraft")) { - return FMLLoader.versionInfo().mcVersion(); - } - - return null; - } - - ModInfo modInfo = FMLLoader.getLoadingModList().getMods().stream().filter(mod -> mod.getModId().equals(modId)).findFirst().orElse(null); - - if (modInfo == null) { - return null; - } - - return modInfo.getVersion().toString(); - } - - @Override - public boolean isDevelopmentEnvironment() { - return !FMLLoader.isProduction(); - } -} \ No newline at end of file diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java deleted file mode 100644 index 2dc4f71be..000000000 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java +++ /dev/null @@ -1,40 +0,0 @@ -package pl.skidam.automodpack_loader_core_neoforge.mods; - -import pl.skidam.automodpack_core.loader.ModpackLoaderService; -import pl.skidam.automodpack_core.utils.FileInspection; -import pl.skidam.automodpack_core.utils.cache.FileMetadataCache; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import static pl.skidam.automodpack_core.Constants.LOGGER; - -public class ModpackLoader implements ModpackLoaderService { - public static String CONNECTOR_MODS_PROPERTY = "connector.additionalModLocations"; - public static List modsToLoad = new ArrayList<>(); - - @Override - public void loadModpack(List modpackMods) { - try { - for (Path modpackMod : modpackMods) { - if (FileInspection.isModCompatible(modpackMod)) { - modsToLoad.add(modpackMod); - } - } - - // set for connector - String paths = modpackMods.stream().map(Path::toString).collect(Collectors.joining(",")); - String finalMods = paths + "," + System.getProperty(CONNECTOR_MODS_PROPERTY, ""); - System.setProperty(CONNECTOR_MODS_PROPERTY, finalMods); - } catch (Exception e) { - LOGGER.error("Error while loading modpack", e); - } - } - - @Override - public List getModpackNestedConflicts(Path modpackDir, FileMetadataCache cache) { - return new ArrayList<>(); - } -} diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/mods.toml b/loader/neoforge/fml2/src/main/resources/META-INF/mods.toml deleted file mode 100644 index 00d2a7044..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/mods.toml +++ /dev/null @@ -1,14 +0,0 @@ -modLoader = "javafml" -loaderVersion = "[1,)" -license = "LGPLv3" - -[[mods]] -modId = "automodpack" -version = "${version}" -displayName = "AutoModpack" -displayURL = "https://modrinth.com/mod/automodpack" -authors = "Skidam" -description = ''' - -''' -logoFile = "icon.png" diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator deleted file mode 100644 index 5f862a77e..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator +++ /dev/null @@ -1 +0,0 @@ -pl.skidam.automodpack_loader_core.LazyModLocator diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator deleted file mode 100644 index f5484f137..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator +++ /dev/null @@ -1 +0,0 @@ -pl.skidam.automodpack_loader_core.EarlyModLocator diff --git a/settings.gradle.kts b/settings.gradle.kts index 7a8bc5854..c9b1ea74a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,7 @@ coreModules.forEach { module -> "fabric-core" -> project.buildFileName = "../../loader-fabric-core.gradle.kts" "fabric-15", "fabric-16" -> project.buildFileName = "../../loader-fabric.gradle.kts" "forge-fml40", "forge-fml47" -> project.buildFileName = "../../loader-forge.gradle.kts" - "neoforge-fml2", "neoforge-fml4", "neoforge-fml10" -> project.buildFileName = "../../loader-neoforge.gradle.kts" + "neoforge-fml4", "neoforge-fml10" -> project.buildFileName = "../../loader-neoforge.gradle.kts" } } @@ -52,7 +52,6 @@ stonecutter { match("1.21.5", "fabric", "neoforge") match("1.21.4", "fabric", "neoforge") match("1.21.1", "fabric", "neoforge") - match("1.20.4", "fabric", "neoforge") match("1.20.1", "fabric", "forge") match("1.19.2", "fabric", "forge") match("1.18.2", "fabric", "forge") diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index bd3dc15d8..58eb02150 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -94,18 +94,6 @@ meta.minecraft = ">=1.21 <=1.21.1" publish_versions = "1.21\n1.21.1" -["1.20.4-fabric"] -deps.fabric-api = "0.97.3+1.20.4" - -["1.20.4-neoforge"] -deps.neoforge = "20.4.248" - -["1.20.4"] -deps.minecraft = "1.20.4" -meta.minecraft = "1.20.4" -publish_versions = "1.20.4" - - ["1.20.1-fabric"] deps.fabric-api = "0.92.7+1.20.1" From 367af7e971c537e296179f253e3e09553ff60231 Mon Sep 17 00:00:00 2001 From: skidam Date: Thu, 21 May 2026 15:46:34 +0200 Subject: [PATCH 11/44] stop redownloading cached minecraft assets --- autotester/docker/client/run-headlessmc-client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 49a4f7e60..9569744bc 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -57,7 +57,7 @@ if [[ "$loader" == "fabric" && -n "$loader_version" ]]; then launch_target="fabric-loader-${loader_version}-${minecraft}" fi -yes | hmc --command "download ${minecraft}" 2>/dev/null || true +echo n | hmc --command "download ${minecraft}" 2>/dev/null || true hmc --command "config -refresh" 2>/dev/null || true exec hmc \ From 8d8d1dffd1b5e1cb2f9f34a6b990766850ed7907 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 17:06:42 +0200 Subject: [PATCH 12/44] run stonecutter set 26.1-fabric version --- .../automodpack/client/autotest/AutoTestBridge.java | 10 +++++----- stonecutter.gradle.kts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java index f67af9359..079fd31cc 100644 --- a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -116,8 +116,8 @@ private static String exec(JsonObject req) throws Exception { }; b.onPress(input); /*?} else {*/ - /*b.onPress();*/ - /*?}*/ + /*b.onPress(); + *//*?}*/ } return ok(); }); @@ -166,8 +166,8 @@ private static String scr(boolean detailed) { /*? if >= 1.19.4 {*/ wo.addProperty("x", aw.getX()); wo.addProperty("y", aw.getY()); /*?} else {*/ - /*wo.addProperty("x", aw.x); wo.addProperty("y", aw.y);*/ - /*?}*/ + /*wo.addProperty("x", aw.x); wo.addProperty("y", aw.y); + *//*?}*/ wo.addProperty("active", aw.active); wo.addProperty("visible", aw.visible); a.add(wo); } @@ -198,7 +198,7 @@ private static String connect(JsonObject req) throws Exception { /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, ServerData.Type.OTHER), false); *//*?} else if >= 1.20.1 {*/ /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, false), false); - */ /*?} else {*/ + *//*?} else {*/ /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, false)); *//*?}*/ f.complete(ok()); diff --git a/stonecutter.gradle.kts b/stonecutter.gradle.kts index eaa120cbe..c852ceb9e 100644 --- a/stonecutter.gradle.kts +++ b/stonecutter.gradle.kts @@ -14,7 +14,7 @@ wiki { } } -stonecutter active "26.2-fabric" /* [SC] DO NOT EDIT */ +stonecutter active "26.1-fabric" /* [SC] DO NOT EDIT */ stonecutter.parameters { val (version, loader) = current.project.split('-', limit = 2) From 5f4d53e434d908dbd246e1dee74524644c507669 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 17:09:27 +0200 Subject: [PATCH 13/44] fix login packets race condition on 1.21.1+ --- .../core/ServerLoginNetworkHandlerMixin.java | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java index 73c4fbad9..d0a229cb5 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java @@ -2,19 +2,19 @@ import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import net.minecraft.server.network.ServerLoginPacketListenerImpl; -import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import pl.skidam.automodpack.networking.client.LoginResponsePayload; import pl.skidam.automodpack.networking.server.ServerLoginNetworkAddon; -import static pl.skidam.automodpack_core.Constants.LOGGER; - @Mixin(value = ServerLoginPacketListenerImpl.class, priority = 300) public abstract class ServerLoginNetworkHandlerMixin { - @Shadow private ServerLoginPacketListenerImpl.State state; + /*? if <= 1.20.1 {*/ + /*@org.spongepowered.asm.mixin.Shadow ServerLoginPacketListenerImpl.State state; + *//*?}*/ @Unique private ServerLoginNetworkAddon automodpack$addon; @Inject( @@ -38,22 +38,31 @@ private void handleCustomPayload(ServerboundCustomQueryAnswerPacket packet, Call // Handle queries if (this.automodpack$addon.handle(packet)) { ci.cancel(); // We have handled it, cancel vanilla behavior - } else { - // Catch unhandled AutoModpack `LoginResponsePayload` packets - it generally shouldn't happen, but just in case and also fabric api does it too... - /*? if >=1.20.2 {*/ - if (packet.payload() instanceof LoginResponsePayload response) { - if (response.data() != null) { - response.data().skipBytes(response.data().readableBytes()); - } - LOGGER.debug("Unhandled LoginResponsePayload in ServerLoginPacketListenerImpl with id: {}", response.id()); - } - /*?}*/ } } + /*? if > 1.20.1 {*/ @Inject( + method = "verifyLoginAndFinishConnectionSetup", + at = @At("HEAD"), + cancellable = true + ) + private void beforeVerifyLogin(CallbackInfo ci) { + if (this.automodpack$addon == null) { + return; + } + + if (!this.automodpack$addon.queryTick()) { + ci.cancel(); + return; + } + + this.automodpack$addon = null; + } + /*?} else {*/ + /*@Inject( method = "tick", - at = @At(value = "HEAD"), + at = @At("HEAD"), cancellable = true ) private void sendOurPackets(CallbackInfo ci) { @@ -61,18 +70,16 @@ private void sendOurPackets(CallbackInfo ci) { return; } - if (state != ServerLoginPacketListenerImpl.State.NEGOTIATING && state != ServerLoginPacketListenerImpl.State./*? if <1.20.2 {*/ /*READY_TO_ACCEPT *//*?} else {*/VERIFYING/*?}*/) { + if (state != ServerLoginPacketListenerImpl.State.NEGOTIATING && state != ServerLoginPacketListenerImpl.State.READY_TO_ACCEPT) { return; } - // Send first automodpack packet if (!this.automodpack$addon.queryTick()) { - // We need more time to process packets ci.cancel(); return; } this.automodpack$addon = null; } - + *//*?}*/ } From 10a70ca475d5c21c3a12f05a4afa1c69923bd3e5 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 18:35:46 +0200 Subject: [PATCH 14/44] more login packets improvements for potential race conditions that were discovered via autotester --- .../server/ServerLoginNetworkAddon.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java b/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java index 6425c1869..3cf52d25e 100644 --- a/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java +++ b/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java @@ -19,6 +19,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; import static pl.skidam.automodpack_core.Constants.LOGGER; @@ -30,6 +31,7 @@ public class ServerLoginNetworkAddon implements PacketSender { private final MinecraftServer server; private final Collection> synchronizers = new ConcurrentLinkedQueue<>(); public final Map channels = new ConcurrentHashMap<>(); + private final AtomicInteger processingCount = new AtomicInteger(0); private boolean firstTick = true; public ServerLoginNetworkAddon(ServerLoginPacketListenerImpl serverLoginNetworkHandler) { @@ -61,7 +63,7 @@ public boolean queryTick() { return true; }); - return this.channels.isEmpty() && this.synchronizers.isEmpty(); + return this.processingCount.get() == 0 && this.channels.isEmpty() && this.synchronizers.isEmpty(); } /** @@ -71,9 +73,14 @@ public boolean queryTick() { * @return true if the packet was handled */ public boolean handle(ServerboundCustomQueryAnswerPacket packet) { - LoginQueryParser loginQuery = new LoginQueryParser(packet); - if (loginQuery.success) return handle(loginQuery.queryId, loginQuery.buf); - return false; + this.processingCount.incrementAndGet(); + try { + LoginQueryParser loginQuery = new LoginQueryParser(packet); + if (loginQuery.success) return handle(loginQuery.queryId, loginQuery.buf); + return false; + } finally { + this.processingCount.decrementAndGet(); + } } private boolean handle(int queryId, @Nullable FriendlyByteBuf originalBuf) { From 7f0ae941a38c168f1f900be4fbc7cf27047f6042 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 18:36:09 +0200 Subject: [PATCH 15/44] dont try injecting bridge on preload it doesnt work --- .../automodpack_loader_core/Preload.java | 467 +++++++++--------- 1 file changed, 225 insertions(+), 242 deletions(-) diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java index 37d49ac1a..890c5dca7 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java @@ -11,7 +11,6 @@ import pl.skidam.automodpack_loader_core.client.ModpackUtils; import pl.skidam.automodpack_loader_core.loader.LoaderManager; import pl.skidam.automodpack_loader_core.mods.ModpackLoader; -import pl.skidam.automodpack_loader_core.screen.ScreenManager; import java.io.IOException; import java.net.InetSocketAddress; @@ -28,247 +27,231 @@ public class Preload { - public Preload() { - try { - long start = System.currentTimeMillis(); - LOGGER.info("Prelaunching AutoModpack..."); - initializeConstants(); - loadConfigs(); - bootstrapAutotestClientHooks(); - updateAll(); - LOGGER.info("AutoModpack prelaunched! took " + (System.currentTimeMillis() - start) + "ms"); - } catch (Exception e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - - private void updateAll() { - var optionalSelectedModpackDir = ModpackContentTools.getModpackDir(clientConfig.selectedModpack); - - if (LOADER_MANAGER.getEnvironmentType() == LoaderManagerService.EnvironmentType.SERVER || optionalSelectedModpackDir.isEmpty()) { - SelfUpdater.update(); - return; - } - - selectedModpackDir = optionalSelectedModpackDir.get(); - InetSocketAddress selectedModpackAddress = null; - InetSocketAddress selectedServerAddress = null; - boolean requiresMagic = true; // Default to true - if (!clientConfig.selectedModpack.isBlank() && clientConfig.installedModpacks.containsKey(clientConfig.selectedModpack)) { - var entry = clientConfig.installedModpacks.get(clientConfig.selectedModpack); - selectedModpackAddress = entry.hostAddress; - selectedServerAddress = entry.serverAddress; - requiresMagic = entry.requiresMagic; - } - - // Only selfupdate if no modpack is selected - if (selectedModpackAddress == null) { - SelfUpdater.update(); - LegacyClientCacheUtils.deleteDummyFiles(); - } else { - Secrets.Secret secret = SecretsStore.getClientSecret(clientConfig.selectedModpack); - - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(selectedModpackAddress, selectedServerAddress, requiresMagic); - var optionalLatestModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, false); - var latestModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); - - // Use the latest modpack content if available - if (optionalLatestModpackContent.isPresent()) { - latestModpackContent = optionalLatestModpackContent.get(); - - // Update AutoModpack to server version only if we can get newest modpack content - if (SelfUpdater.update(latestModpackContent)) { - return; - } - } - - // Delete dummy files - LegacyClientCacheUtils.deleteDummyFiles(); + public Preload() { + try { + long start = System.currentTimeMillis(); + LOGGER.info("Prelaunching AutoModpack..."); + initializeConstants(); + loadConfigs(); + updateAll(); + LOGGER.info("AutoModpack prelaunched! took " + (System.currentTimeMillis() - start) + "ms"); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private void updateAll() { + var optionalSelectedModpackDir = ModpackContentTools.getModpackDir(clientConfig.selectedModpack); + + if (LOADER_MANAGER.getEnvironmentType() == LoaderManagerService.EnvironmentType.SERVER || optionalSelectedModpackDir.isEmpty()) { + SelfUpdater.update(); + return; + } + + selectedModpackDir = optionalSelectedModpackDir.get(); + InetSocketAddress selectedModpackAddress = null; + InetSocketAddress selectedServerAddress = null; + boolean requiresMagic = true; // Default to true + if (!clientConfig.selectedModpack.isBlank() && clientConfig.installedModpacks.containsKey(clientConfig.selectedModpack)) { + var entry = clientConfig.installedModpacks.get(clientConfig.selectedModpack); + selectedModpackAddress = entry.hostAddress; + selectedServerAddress = entry.serverAddress; + requiresMagic = entry.requiresMagic; + } + + // Only selfupdate if no modpack is selected + if (selectedModpackAddress == null) { + SelfUpdater.update(); + LegacyClientCacheUtils.deleteDummyFiles(); + } else { + Secrets.Secret secret = SecretsStore.getClientSecret(clientConfig.selectedModpack); + + Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(selectedModpackAddress, selectedServerAddress, requiresMagic); + var optionalLatestModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, false); + var latestModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); + + // Use the latest modpack content if available + if (optionalLatestModpackContent.isPresent()) { + latestModpackContent = optionalLatestModpackContent.get(); + + // Update AutoModpack to server version only if we can get newest modpack content + if (SelfUpdater.update(latestModpackContent)) { + return; + } + } + + // Delete dummy files + LegacyClientCacheUtils.deleteDummyFiles(); var modpackUpdaterInstance = new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir); - if (clientConfig.updateSelectedModpackOnLaunch) { // Check updates and load the modpack - modpackUpdaterInstance.processModpackUpdate(null); - } else { // Otherwise just load the modpack - try { - modpackUpdaterInstance.CheckAndLoadModpack(); - } catch (Exception e) { - LOGGER.error("Failed to check and load modpack, trying to update it", e); - } - } - } - } - - - private void initializeConstants() { - // Initialize global variables - preload = true; - PRELOAD_TIME = System.currentTimeMillis(); - LOADER_MANAGER = new LoaderManager(); - MODPACK_LOADER = new ModpackLoader(); - MC_VERSION = LOADER_MANAGER.getModVersion("minecraft"); - LOADER_VERSION = LOADER_MANAGER.getLoaderVersion(); - LOADER = LOADER_MANAGER.getPlatformType().toString().toLowerCase(); - THIS_MOD_JAR = JarUtils.getJarPath(this.getClass()); - AM_VERSION = FileInspection.getModVersion(THIS_MOD_JAR); - MODS_DIR = THIS_MOD_JAR.getParent(); - - // Get "overrides-automodpack-client.json" zipfile from the AUTOMODPACK_JAR - try (ZipInputStream zis = new ZipInputStream(new LockFreeInputStream(THIS_MOD_JAR))) { - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (entry.getName().equals(clientConfigFileOverrideResource)) { - clientConfigOverride = new String(zis.readAllBytes(), StandardCharsets.UTF_8); - break; - } - } - } catch (IOException e) { - LOGGER.error("Failed to read overrides from jar", e); - } - } - - private void loadConfigs() { - long startTime = System.currentTimeMillis(); - - // load client config - if (clientConfigOverride == null) { - var clientConfigVersion = ConfigTools.softLoad(clientConfigFile, Jsons.VersionConfigField.class); - if (clientConfigVersion != null) { - if (clientConfigVersion.DO_NOT_CHANGE_IT == 1) { - // Update the configs schemes to not crash the game if loaded with old config! - var clientConfigV1 = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV1.class); - if (clientConfigV1 != null) { // update to V2 - just delete the installedModpacks - clientConfigVersion.DO_NOT_CHANGE_IT = 2; - clientConfigV1.DO_NOT_CHANGE_IT = 2; - clientConfigV1.installedModpacks = null; - } - - ConfigTools.save(clientConfigFile, clientConfigV1); - LOGGER.info("Updated client config version to {}", clientConfigVersion.DO_NOT_CHANGE_IT); - } - } - - clientConfig = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV2.class); - } else { - // TODO: when connecting to the new server which provides modpack different modpack, ask the user if they want, stop using overrides - LOGGER.warn("You are using unofficial {} mod", MOD_ID); - LOGGER.warn("Using client config overrides! Editing the {} file will have no effect", clientConfigFile); - LOGGER.warn("Remove the {} file from inside the jar or remove and download fresh {} mod jar from modrinth/curseforge", clientConfigFileOverrideResource, MOD_ID); - clientConfig = ConfigTools.load(clientConfigOverride, Jsons.ClientConfigFieldsV2.class); - } - - var serverConfigVersion = ConfigTools.softLoad(serverConfigFile, Jsons.VersionConfigField.class); - if (serverConfigVersion != null) { - if (serverConfigVersion.DO_NOT_CHANGE_IT == 1) { - // Update the configs schemes to make this update not as breaking as it could be - var serverConfigV1 = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV1.class); - var serverConfigV2 = ConfigTools.softLoad(serverConfigFile, Jsons.ServerConfigFieldsV2.class); - if (serverConfigV1 != null && serverConfigV2 != null) { - serverConfigVersion.DO_NOT_CHANGE_IT = 2; - serverConfigV2.DO_NOT_CHANGE_IT = 2; - - if (serverConfigV1.hostIp.isBlank()) { - serverConfigV2.addressToSend = ""; - } else { - serverConfigV2.addressToSend = AddressHelpers.parse(serverConfigV1.hostIp).getHostString(); - } - - if (serverConfigV1.hostModpackOnMinecraftPort) { - serverConfigV2.bindPort = -1; - serverConfigV2.portToSend = -1; - } else { - serverConfigV2.bindPort = serverConfigV1.hostPort; - serverConfigV2.portToSend = serverConfigV1.hostPort; - } - } - - ConfigTools.save(serverConfigFile, serverConfigV2); - LOGGER.info("Updated server config version to {}", serverConfigVersion.DO_NOT_CHANGE_IT); - } - } - - // load server config - serverConfig = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV2.class); - - if (serverConfig != null) { - // Add current loader to the list - if (serverConfig.acceptedLoaders == null) { - serverConfig.acceptedLoaders = Set.of(LOADER); - } else { - serverConfig.acceptedLoaders.add(LOADER); - } - - // Check modpack name and fix it if needed, because it will be used for naming a folder on client - if (!serverConfig.modpackName.isEmpty() && FileInspection.isInValidFileName(serverConfig.modpackName)) { - serverConfig.modpackName = FileInspection.fixFileName(serverConfig.modpackName); - LOGGER.info("Changed modpack name to {}", serverConfig.modpackName); - } - - ConfigUtils.normalizeServerConfig(serverConfig); - - // Save changes - ConfigTools.save(serverConfigFile, serverConfig); - } - - if (clientConfig != null) { - // Very important to have this map initialized - if (clientConfig.installedModpacks == null) { - clientConfig.installedModpacks = new HashMap<>(); - } - - if (clientConfig.selectedModpack == null) { - clientConfig.selectedModpack = ""; - } - - // Save changes - ConfigTools.save(clientConfigFile, clientConfig); - } - - knownHosts = ConfigTools.load(knownHostsFile, Jsons.KnownHostsFields.class); - if (knownHosts != null) { - if (knownHosts.hosts == null) { - knownHosts.hosts = new HashMap<>(); - } - } - - try { - Files.createDirectories(privateDir); - String os = System.getProperty("os.name").toLowerCase(); - try { - if (os.contains("win")) { - Files.setAttribute(privateDir, "dos:hidden", true); - } else if (os.contains("nix") || os.contains("nux") || os.contains("aix") || os.contains("mac")) { - Set perms = PosixFilePermissions.fromString("rwx------"); // Corresponds to 0700 - Files.setPosixFilePermissions(privateDir, perms); - } - } catch (UnsupportedOperationException | IOException e) { - LOGGER.debug("Failed to set private directory attributes for os: {}", os); - } - } catch (IOException e) { - LOGGER.error("Failed to create private directory", e); - } - - - if (serverConfig == null || clientConfig == null) { - throw new RuntimeException("Failed to load config!"); - } - - LOGGER.info("Loaded config! took {}ms", System.currentTimeMillis() - startTime); - } - - private void bootstrapAutotestClientHooks() { - if (!Boolean.getBoolean("automodpack.autotest")) { - return; - } - if (LOADER_MANAGER.getEnvironmentType() != LoaderManagerService.EnvironmentType.CLIENT) { - return; - } - try { - Class bridgeClass = Class.forName("pl.skidam.automodpack.client.autotest.AutoTestBridge"); - bridgeClass.getMethod("startIfEnabled").invoke(null); - } catch (Throwable e) { - LOGGER.warn("Failed to start AutoModpack autotest bridge during prelaunch", e); - } - } -} + if (clientConfig.updateSelectedModpackOnLaunch) { // Check updates and load the modpack + modpackUpdaterInstance.processModpackUpdate(null); + } else { // Otherwise just load the modpack + try { + modpackUpdaterInstance.CheckAndLoadModpack(); + } catch (Exception e) { + LOGGER.error("Failed to check and load modpack, trying to update it", e); + } + } + } + } + + + private void initializeConstants() { + // Initialize global variables + preload = true; + PRELOAD_TIME = System.currentTimeMillis(); + LOADER_MANAGER = new LoaderManager(); + MODPACK_LOADER = new ModpackLoader(); + MC_VERSION = LOADER_MANAGER.getModVersion("minecraft"); + LOADER_VERSION = LOADER_MANAGER.getLoaderVersion(); + LOADER = LOADER_MANAGER.getPlatformType().toString().toLowerCase(); + THIS_MOD_JAR = JarUtils.getJarPath(this.getClass()); + AM_VERSION = FileInspection.getModVersion(THIS_MOD_JAR); + MODS_DIR = THIS_MOD_JAR.getParent(); + + // Get "overrides-automodpack-client.json" zipfile from the AUTOMODPACK_JAR + try (ZipInputStream zis = new ZipInputStream(new LockFreeInputStream(THIS_MOD_JAR))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals(clientConfigFileOverrideResource)) { + clientConfigOverride = new String(zis.readAllBytes(), StandardCharsets.UTF_8); + break; + } + } + } catch (IOException e) { + LOGGER.error("Failed to read overrides from jar", e); + } + } + + private void loadConfigs() { + long startTime = System.currentTimeMillis(); + + // load client config + if (clientConfigOverride == null) { + var clientConfigVersion = ConfigTools.softLoad(clientConfigFile, Jsons.VersionConfigField.class); + if (clientConfigVersion != null) { + if (clientConfigVersion.DO_NOT_CHANGE_IT == 1) { + // Update the configs schemes to not crash the game if loaded with old config! + var clientConfigV1 = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV1.class); + if (clientConfigV1 != null) { // update to V2 - just delete the installedModpacks + clientConfigVersion.DO_NOT_CHANGE_IT = 2; + clientConfigV1.DO_NOT_CHANGE_IT = 2; + clientConfigV1.installedModpacks = null; + } + + ConfigTools.save(clientConfigFile, clientConfigV1); + LOGGER.info("Updated client config version to {}", clientConfigVersion.DO_NOT_CHANGE_IT); + } + } + + clientConfig = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV2.class); + } else { + // TODO: when connecting to the new server which provides modpack different modpack, ask the user if they want, stop using overrides + LOGGER.warn("You are using unofficial {} mod", MOD_ID); + LOGGER.warn("Using client config overrides! Editing the {} file will have no effect", clientConfigFile); + LOGGER.warn("Remove the {} file from inside the jar or remove and download fresh {} mod jar from modrinth/curseforge", clientConfigFileOverrideResource, MOD_ID); + clientConfig = ConfigTools.load(clientConfigOverride, Jsons.ClientConfigFieldsV2.class); + } + + var serverConfigVersion = ConfigTools.softLoad(serverConfigFile, Jsons.VersionConfigField.class); + if (serverConfigVersion != null) { + if (serverConfigVersion.DO_NOT_CHANGE_IT == 1) { + // Update the configs schemes to make this update not as breaking as it could be + var serverConfigV1 = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV1.class); + var serverConfigV2 = ConfigTools.softLoad(serverConfigFile, Jsons.ServerConfigFieldsV2.class); + if (serverConfigV1 != null && serverConfigV2 != null) { + serverConfigVersion.DO_NOT_CHANGE_IT = 2; + serverConfigV2.DO_NOT_CHANGE_IT = 2; + + if (serverConfigV1.hostIp.isBlank()) { + serverConfigV2.addressToSend = ""; + } else { + serverConfigV2.addressToSend = AddressHelpers.parse(serverConfigV1.hostIp).getHostString(); + } + + if (serverConfigV1.hostModpackOnMinecraftPort) { + serverConfigV2.bindPort = -1; + serverConfigV2.portToSend = -1; + } else { + serverConfigV2.bindPort = serverConfigV1.hostPort; + serverConfigV2.portToSend = serverConfigV1.hostPort; + } + } + + ConfigTools.save(serverConfigFile, serverConfigV2); + LOGGER.info("Updated server config version to {}", serverConfigVersion.DO_NOT_CHANGE_IT); + } + } + + // load server config + serverConfig = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV2.class); + + if (serverConfig != null) { + // Add current loader to the list + if (serverConfig.acceptedLoaders == null) { + serverConfig.acceptedLoaders = Set.of(LOADER); + } else { + serverConfig.acceptedLoaders.add(LOADER); + } + + // Check modpack name and fix it if needed, because it will be used for naming a folder on client + if (!serverConfig.modpackName.isEmpty() && FileInspection.isInValidFileName(serverConfig.modpackName)) { + serverConfig.modpackName = FileInspection.fixFileName(serverConfig.modpackName); + LOGGER.info("Changed modpack name to {}", serverConfig.modpackName); + } + + ConfigUtils.normalizeServerConfig(serverConfig); + + // Save changes + ConfigTools.save(serverConfigFile, serverConfig); + } + + if (clientConfig != null) { + // Very important to have this map initialized + if (clientConfig.installedModpacks == null) { + clientConfig.installedModpacks = new HashMap<>(); + } + + if (clientConfig.selectedModpack == null) { + clientConfig.selectedModpack = ""; + } + + // Save changes + ConfigTools.save(clientConfigFile, clientConfig); + } + + knownHosts = ConfigTools.load(knownHostsFile, Jsons.KnownHostsFields.class); + if (knownHosts != null) { + if (knownHosts.hosts == null) { + knownHosts.hosts = new HashMap<>(); + } + } + + try { + Files.createDirectories(privateDir); + String os = System.getProperty("os.name").toLowerCase(); + try { + if (os.contains("win")) { + Files.setAttribute(privateDir, "dos:hidden", true); + } else if (os.contains("nix") || os.contains("nux") || os.contains("aix") || os.contains("mac")) { + Set perms = PosixFilePermissions.fromString("rwx------"); // Corresponds to 0700 + Files.setPosixFilePermissions(privateDir, perms); + } + } catch (UnsupportedOperationException | IOException e) { + LOGGER.debug("Failed to set private directory attributes for os: {}", os); + } + } catch (IOException e) { + LOGGER.error("Failed to create private directory", e); + } + + + if (serverConfig == null || clientConfig == null) { + throw new RuntimeException("Failed to load config!"); + } + + LOGGER.info("Loaded config! took {}ms", System.currentTimeMillis() - startTime); + } +} \ No newline at end of file From 3c7edbfd5faff67f025418d62c036c4fa1ce45f6 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 20:09:14 +0200 Subject: [PATCH 16/44] Go back to tick method for better/earlier injection point for all versions + better docs why --- .../core/ServerLoginNetworkHandlerMixin.java | 112 +++++++++++------- 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java index d0a229cb5..77b0cdca9 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java @@ -3,6 +3,7 @@ import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import net.minecraft.server.network.ServerLoginPacketListenerImpl; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -10,69 +11,99 @@ import pl.skidam.automodpack.networking.server.ServerLoginNetworkAddon; @Mixin(value = ServerLoginPacketListenerImpl.class, priority = 300) -public abstract class ServerLoginNetworkHandlerMixin { +public abstract class ServerLoginNetworkHandlerMixin { - /*? if <= 1.20.1 {*/ - /*@org.spongepowered.asm.mixin.Shadow ServerLoginPacketListenerImpl.State state; - *//*?}*/ - @Unique private ServerLoginNetworkAddon automodpack$addon; + /* + * State enum per target version: + * 1.18.2 - 1.20.1: HELLO, KEY, AUTHENTICATING, NEGOTIATING, + * READY_TO_ACCEPT, DELAY_ACCEPT, ACCEPTED + * 1.21.1+: HELLO, KEY, AUTHENTICATING, NEGOTIATING, + * VERIFYING, WAITING_FOR_DUPE_DISCONNECT, + * PROTOCOL_SWITCHING, ACCEPTED + * + * tick() per state (all versions): + * HELLO, KEY, AUTHENTICATING: no-op (just timeout counter) + * NEGOTIATING: no-op (just timeout counter) + * READY_TO_ACCEPT (≤ 1.20.1): calls handleAcceptedLogin() + * — login finalization + compression setup + * VERIFYING (≥ 1.21.1): calls verifyLoginAndFinishConnectionSetup() + * — login finalization + compression setup + * DELAY_ACCEPT (≤ 1.20.1): duplicate player check + * WAITING_FOR_DUPE_DISCONNECT: duplicate player check + */ + @Shadow + private ServerLoginPacketListenerImpl.State state; - @Inject( - method = "", - at = @At("RETURN") - ) + @Unique + private ServerLoginNetworkAddon automodpack$addon; + + @Inject(method = "", at = @At("RETURN")) private void initAddon(CallbackInfo ci) { this.automodpack$addon = new ServerLoginNetworkAddon((ServerLoginPacketListenerImpl) (Object) this); } - @Inject( - method = "handleCustomQueryPacket", - at = @At("HEAD"), - cancellable = true - ) + @Inject(method = "handleCustomQueryPacket", at = @At("HEAD"), cancellable = true) private void handleCustomPayload(ServerboundCustomQueryAnswerPacket packet, CallbackInfo ci) { if (this.automodpack$addon == null) { return; } - // Handle queries if (this.automodpack$addon.handle(packet)) { - ci.cancel(); // We have handled it, cancel vanilla behavior + ci.cancel(); } } - /*? if > 1.20.1 {*/ - @Inject( - method = "verifyLoginAndFinishConnectionSetup", - at = @At("HEAD"), - cancellable = true - ) - private void beforeVerifyLogin(CallbackInfo ci) { + /* + * We intercept tick() to hold the login in progress while our query + * exchange completes. Both NEGOTIATING and READY_TO_ACCEPT/VERIFYING + * are checked, but for different reasons: + * + * NEGOTIATING — START queries. tick() is a no-op here, so we use + * this state to call queryTick() which sends the initial handshake + * query. Cancel is harmless (tick does nothing critical). + * + * READY_TO_ACCEPT / VERIFYING — PREVENT finalization. In this state + * tick() calls the login finaliser (handleAcceptedLogin / + * verifyLoginAndFinishConnectionSetup) which sets up compression and + * places the player. We MUST cancel to delay this step until queries + * are finished. + * + * + * == Why not just NEGOTIATING? == + * + * NEGOTIATING is not always entered. On servers with no proxy (the + * common case), handleLoginHello / handleAuthentication sets state + * directly to READY_TO_ACCEPT/VERIFYING, bypassing NEGOTIATING + * entirely. If we only checked NEGOTIATING, those connections would + * never start queries, tick() would finalise immediately, and the + * late query response would hit a compression- or protocol-swapped + * pipeline ("Pipeline has no outbound protocol configured"). + * + * We check NEGOTIATING too as an optimization: when the state DOES + * pass through NEGOTIATING (online mode, proxy-enabled servers), + * we kick off queries a few ticks earlier instead of waiting for + * READY_TO_ACCEPT/VERIFYING. + * + * + * HELLO / KEY / AUTHENTICATING are not intercepted because they run + * before the connection is ready for custom queries, and tick() does + * non-trivial work in those states. + */ + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void sendOurPackets(CallbackInfo ci) { if (this.automodpack$addon == null) { return; } - if (!this.automodpack$addon.queryTick()) { - ci.cancel(); + /*? if <= 1.20.1 {*/ + /*if (this.state != ServerLoginPacketListenerImpl.State.NEGOTIATING && this.state != ServerLoginPacketListenerImpl.State.READY_TO_ACCEPT) { return; } - - this.automodpack$addon = null; - } - /*?} else {*/ - /*@Inject( - method = "tick", - at = @At("HEAD"), - cancellable = true - ) - private void sendOurPackets(CallbackInfo ci) { - if (this.automodpack$addon == null) { - return; - } - - if (state != ServerLoginPacketListenerImpl.State.NEGOTIATING && state != ServerLoginPacketListenerImpl.State.READY_TO_ACCEPT) { + *//*?} else {*/ + if (this.state != ServerLoginPacketListenerImpl.State.NEGOTIATING && this.state != ServerLoginPacketListenerImpl.State.VERIFYING) { return; } + /*?}*/ if (!this.automodpack$addon.queryTick()) { ci.cancel(); @@ -81,5 +112,4 @@ private void sendOurPackets(CallbackInfo ci) { this.automodpack$addon = null; } - *//*?}*/ } From 71b622c3d55e2e0030da16cef063439dc09ad150 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 20:35:47 +0200 Subject: [PATCH 17/44] autotester: fix TOCTOU races, poll jitter, log tail, connect health check, click_restart error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix TOCTOU races in accept_fingerprint and wait_join: capture get_screen once instead of calling it 4x per iteration (was reading different screen states) - Add _jitter_sleep helper with ±20% random jitter to all poll loops, reducing thundering herd file-I/O contention when multiple tests run concurrently under jobs > 1 - _container_logs now accepts tail=N to fetch only last N lines instead of full log; _wait_for_log and _read_fingerprint use tail=200 - _phase_connect: add _assert_running health check, increase inner poll from 15s to 45s (overloaded PC may need longer), compute remaining time - _phase_click_restart: raise RuntimeError if no restart button found instead of silently passing and hanging on _wait_exited - BridgeClient.request accepts per-call timeout= kwarg; wait_bridge uses timeout=5 for initial ping (was 30s, kept stalling when Java bridge hadn't entered its poll loop yet) - BridgeClient internal poll uses random.uniform(0.03, 0.07) jitter --- autotester/automodpack_autotester/bridge.py | 9 +- autotester/automodpack_autotester/runner.py | 128 +++++++++++--------- 2 files changed, 74 insertions(+), 63 deletions(-) diff --git a/autotester/automodpack_autotester/bridge.py b/autotester/automodpack_autotester/bridge.py index 191fc5eb8..432e44b29 100644 --- a/autotester/automodpack_autotester/bridge.py +++ b/autotester/automodpack_autotester/bridge.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import random import time from dataclasses import dataclass from pathlib import Path @@ -11,7 +12,7 @@ class BridgeClient: game_dir: Path token: str - def request(self, op: str, **payload) -> dict: + def request(self, op: str, timeout: float = 30, **payload) -> dict: autotest_dir = self.game_dir / "automodpack" / "autotest" autotest_dir.mkdir(parents=True, exist_ok=True) cmd = autotest_dir / "bridge-command.json" @@ -22,7 +23,7 @@ def request(self, op: str, **payload) -> dict: json.dumps({"token": self.token, "op": op, **payload}), encoding="utf-8" ) tmp.rename(cmd) - deadline = time.monotonic() + 30 + deadline = time.monotonic() + timeout while time.monotonic() < deadline: if rsp.exists(): data = json.loads(rsp.read_text(encoding="utf-8")) @@ -30,5 +31,5 @@ def request(self, op: str, **payload) -> dict: if not data.get("ok"): raise RuntimeError(f"Bridge error on '{op}': {data.get('error', data)}") return data - time.sleep(0.05) - raise TimeoutError(f"Bridge did not respond to '{op}' after 30s") + time.sleep(random.uniform(0.03, 0.07)) + raise TimeoutError(f"Bridge did not respond to '{op}' after {timeout}s") diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index 965ea7735..dab24b959 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -3,6 +3,7 @@ import json import logging import os +import random import re import secrets import shutil @@ -22,13 +23,20 @@ _docker = docker_py.from_env() +def _jitter_sleep(base, fraction=0.2): + time.sleep(random.uniform(base * (1 - fraction), base * (1 + fraction))) + + def _container(name): return _docker.containers.get(name) -def _container_logs(name): +def _container_logs(name, tail=None): try: - return _container(name).logs().decode("utf-8", errors="replace") + kwargs = {} + if tail is not None: + kwargs["tail"] = tail + return _container(name).logs(**kwargs).decode("utf-8", errors="replace") except docker_py.errors.NotFound: return "" @@ -90,10 +98,10 @@ def _inspect_container(name): def _wait_for_log(name, needle, timeout): deadline = time.monotonic() + timeout while time.monotonic() < deadline: - if needle in _container_logs(name): + if needle in _container_logs(name, tail=200): return _assert_running(name) - time.sleep(2) + _jitter_sleep(2) raise TimeoutError(f"Timeout waiting for {needle!r} in {name}") @@ -104,7 +112,7 @@ def _wait_exited(name, timeout): c.reload() if not c.attrs.get("State", {}).get("Running", False): return - time.sleep(1) + _jitter_sleep(1) raise TimeoutError(f"Timeout waiting for {name} to exit") @@ -140,7 +148,7 @@ def _await(pred, timeout, msg): r = pred() if r is not None: return r - time.sleep(0.5) + _jitter_sleep(0.5) raise TimeoutError(msg) @@ -367,7 +375,7 @@ def _launch_client(ctx, target, client_image): ], user=f"{_uid()}:{_gid()}", ) - time.sleep(1) + _jitter_sleep(1) _assert_running(ctx["cli_name"]) @@ -398,11 +406,11 @@ def _phase_wait_bridge(ctx): ) if _bridge_state(ctx).exists(): try: - ctx["bridge"].request("ping") + ctx["bridge"].request("ping", timeout=5) return except Exception: pass - time.sleep(1) + _jitter_sleep(1) raise TimeoutError(f"Bridge for {ctx['target'].id} did not become available within {to}s") @@ -414,7 +422,7 @@ def _phase_click_continue(ctx): try: r = bridge.request("get_widgets") except (TimeoutError, RuntimeError): - time.sleep(1) + _jitter_sleep(1) continue if "TitleScreen" in str(r.get("screenClass", "")) or "class_442" in str( r.get("screenClass", "") @@ -425,9 +433,9 @@ def _phase_click_continue(ctx): bridge.request("click", selector={"text": "Continue"}) except (TimeoutError, RuntimeError): pass - time.sleep(1) + _jitter_sleep(1) continue - time.sleep(0.5) + _jitter_sleep(0.5) @_reg("read_fingerprint") @@ -435,7 +443,7 @@ def _phase_read_fingerprint(ctx): to = float(ctx["scenario"].get("timeouts", {}).get("serverStartSeconds", 180)) dl = time.monotonic() + to while time.monotonic() < dl: - logs = _container_logs(ctx["srv_name"]) + logs = _container_logs(ctx["srv_name"], tail=200) for line in logs.splitlines(): m = re.search( r"(?:certificate\s+)?fingerprint[:\s]+([0-9A-Fa-f:]+)", line, re.IGNORECASE @@ -443,7 +451,7 @@ def _phase_read_fingerprint(ctx): if m: ctx["fingerprint"] = m.group(1) return - time.sleep(1) + _jitter_sleep(1) raise RuntimeError("No TLS fingerprint found in server logs") @@ -457,17 +465,19 @@ def _phase_connect(ctx): _CONNECT = ("ConnectScreen", "class_397") while time.monotonic() < deadline: + _assert_running(ctx["cli_name"]) bridge.request("connect", host=host, port=25565) - poll_dl = time.monotonic() + 15 + remaining = deadline - time.monotonic() + poll_dl = time.monotonic() + min(remaining, 45) while time.monotonic() < poll_dl: screen = str(bridge.request("get_screen").get("screenClass") or "") if any(n in screen for n in _TITLE): break if not any(n in screen for n in _CONNECT): return - time.sleep(0.5) + _jitter_sleep(0.5) bridge.request("set_screen") - time.sleep(1) + _jitter_sleep(1) raise RuntimeError("Could not connect after multiple attempts") @@ -493,19 +503,16 @@ def _phase_accept_fingerprint(ctx): if not fp: raise RuntimeError("No fingerprint — run read_fingerprint phase first") ctx["bridge"].request("verify_fingerprint", fingerprint=fp) - _await( - lambda: ( - any( - n in str(ctx["bridge"].request("get_screen").get("screenClass", "")) - for n in ("DangerScreen", "DownloadScreen", "RestartScreen") - ) - or "FingerprintVerificationScreen" - not in str(ctx["bridge"].request("get_screen").get("screenClass", "")) - or None - ), - 20, - "Fingerprint verification did not complete", - ) + + def _check(): + screen_class = str(ctx["bridge"].request("get_screen").get("screenClass", "")) + if any(n in screen_class for n in ("DangerScreen", "DownloadScreen", "RestartScreen")): + return True + if "FingerprintVerificationScreen" not in screen_class: + return True + return None + + _await(_check, 20, "Fingerprint verification did not complete") @_reg("skip_fingerprint") @@ -534,7 +541,7 @@ def _phase_skip_fingerprint(ctx): ): bridge.request("click", selector={"widgetId": w["id"]}) return - time.sleep(1) + _jitter_sleep(1) raise RuntimeError("Skip button did not activate") @@ -559,7 +566,7 @@ def _phase_click_confirm(ctx): widgets = bridge.request("get_widgets").get("widgets", []) if widgets: break - time.sleep(0.2) + _jitter_sleep(0.2) for w in reversed(widgets): if w.get("type") == "Button" and w.get("active", False): bridge.request("click", widgetId=int(w.get("id", -1))) @@ -593,7 +600,7 @@ def _phase_verify_files(ctx): while time.monotonic() < dl: if all((mp_root / rel).exists() for rel, _ in ctx["scenario_files"]): return - time.sleep(2) + _jitter_sleep(2) missing = [ str(rel) for rel, _ in ctx["scenario_files"] if not (mp_root / rel).exists() ] @@ -611,7 +618,7 @@ def _phase_verify_mods(ctx): mods = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() if all(any(fnmatch(m, p) for m in mods) for p in ctx["expected_mods"]): return - time.sleep(2) + _jitter_sleep(2) existing = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() missing = [ p for p in ctx["expected_mods"] if not any(fnmatch(m, p) for m in existing) @@ -629,19 +636,20 @@ def _phase_click_restart(ctx): except TimeoutError: continue if "RestartScreen" in str(screen.get("screenClass", "")): - try: - widgets = bridge.request("get_widgets").get("widgets", []) - action_labels = ("close", "restart", "quit") - for w in reversed(widgets): - txt = str(w.get("text", "")).lower() - if w.get("type") == "Button" and w.get("active", False) and any(l in txt for l in action_labels): - bridge.request("click", widgetId=int(w.get("id", -1))) - break - except RuntimeError: - pass + widgets = bridge.request("get_widgets").get("widgets", []) + clicked = False + action_labels = ("close", "restart", "quit") + for w in reversed(widgets): + txt = str(w.get("text", "")).lower() + if w.get("type") == "Button" and w.get("active", False) and any(label in txt for label in action_labels): + bridge.request("click", widgetId=int(w.get("id", -1))) + clicked = True + break + if not clicked: + raise RuntimeError("No restart button found on RestartScreen") _wait_exited(ctx["cli_name"], timeout=90) return - time.sleep(0.5) + _jitter_sleep(0.5) @_reg("quit") @@ -676,17 +684,19 @@ def _phase_launch_client(ctx): def _phase_wait_join(ctx): bridge = ctx["bridge"] to = float(ctx["scenario"].get("timeouts", {}).get("rejoinSeconds", 180)) - _await( - lambda: ( - "FingerprintVerificationScreen" - not in str(bridge.request("get_screen").get("screenClass", "")) - and "DownloadScreen" - not in str(bridge.request("get_screen").get("screenClass", "")) - and "RestartScreen" - not in str(bridge.request("get_screen").get("screenClass", "")) - and bridge.request("get_screen").get("screenClass") is None - or None - ), - to, - f"{ctx['target'].id}: Player did not join in-game within {to}s", - ) + + def _check(): + screen = bridge.request("get_screen") + screen_class = screen.get("screenClass") + if screen_class is None: + return True + name = str(screen_class) + if "FingerprintVerificationScreen" in name: + return None + if "DownloadScreen" in name: + return None + if "RestartScreen" in name: + return None + return True + + _await(_check, to, f"{ctx['target'].id}: Player did not join in-game within {to}s") From d7454a108402f20285af970d375ca0913843bc46 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 22 May 2026 21:11:11 +0200 Subject: [PATCH 18/44] save but broken --- autotester/automodpack_autotester/runner.py | 38 +++++++------------ autotester/docker/client/Dockerfile | 16 ++++++-- .../docker/client/run-headlessmc-client | 26 ++++++++----- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index dab24b959..ec6fa07ce 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -71,14 +71,17 @@ def _remove_volume(name): pass -def _run_container(name, image, network, env, mounts, command=None, user=None): +def _run_container(name, image, network, env, mounts, command=None, user=None, entrypoint=None): volumes = {} for host, container_path, readonly in mounts: volumes[str(host)] = {"bind": container_path, "mode": "ro" if readonly else "rw"} - return _docker.containers.run( - image, detach=True, name=name, network=network, + kwargs = dict( + image=image, detach=True, name=name, network=network, environment=dict(env), volumes=volumes, command=command, user=user, ) + if entrypoint is not None: + kwargs["entrypoint"] = entrypoint + return _docker.containers.run(**kwargs) def _assert_running(name): @@ -165,14 +168,13 @@ def run_case( case_dir = out_dir / f"{target.id}-{int(time.time())}-{secrets.token_hex(3)}" server_dir = case_dir / "server" game_dir = case_dir / "client" / "game" - cache_dir = case_dir / "hmc-cache" net_name = f"amp-{secrets.token_hex(4)}"[:63] srv_name = f"amp-s-{secrets.token_hex(4)}"[:63] cli_name = f"amp-c-{secrets.token_hex(4)}"[:63] token = secrets.token_hex(16) ctx = dict(locals()) - for d in (server_dir, game_dir, cache_dir): + for d in (server_dir, game_dir): d.mkdir(parents=True, exist_ok=True) try: @@ -194,7 +196,6 @@ def run_case( ctx["expected_mods"] = [str(m) for m in sf.get("expectedMods", [])] _prepare_server(ctx, target, settings) - _seed_cache(ctx) _ensure_network(net_name) flow = scenario.get("flow", []) @@ -266,17 +267,6 @@ def _prepare_server(ctx, target, settings): f.write_text(content) -def _seed_cache(ctx): - cache_seed = (ctx["out_dir"].parent / ".hmc-cache").resolve() - if cache_seed.exists() and cache_seed != ctx["cache_dir"]: - shutil.copytree( - cache_seed, - ctx["cache_dir"], - copy_function=shutil.copy2, - dirs_exist_ok=True, - ) - - def _launch_server(ctx, target, scenario, settings): topo = scenario.get("topology", {}).get("server", {}) srv_type = topo.get("type") or settings.get("serverTypes", {}).get(target.loader) @@ -346,11 +336,11 @@ def _launch_client(ctx, target, client_image): game_dir = ctx["game_dir"] (game_dir / "mods").mkdir(parents=True, exist_ok=True) shutil.copy2(ctx["artifact"], game_dir / "mods" / "automodpack.jar") - if target.loader in ("forge", "neoforge"): - (game_dir / "config").mkdir(parents=True, exist_ok=True) - (game_dir / "config" / "fml.toml").write_text( - 'disableConfigWatcher = false\nearlyWindowControl = false\nmaxThreads = -1\nversionCheck = true\ndefaultConfigPath = "defaultconfigs"\ndisableOptimizedDFU = true\nearlyWindowProvider = "fmlearlywindow"\nearlyWindowWidth = 854\nearlyWindowHeight = 480\nearlyWindowMaximized = false\ndebugOpenGl = false\nearlyWindowFBScale = 1\nearlyWindowSkipGLVersions = []\nearlyWindowSquir = false\nearlyLoadingScreenTheme = ""\ndependencyOverrides = {}\n' - ) + + # Mount the persistent HMC cache directly (no per-case copy) + hmc_cache_root = (ctx["out_dir"].parent / ".hmc-cache").resolve() + hmc_cache_root.mkdir(parents=True, exist_ok=True) + _run_container( name=ctx["cli_name"], image=client_image, @@ -358,11 +348,11 @@ def _launch_client(ctx, target, client_image): env={ "AM_AUTOTEST_BRIDGE_TOKEN": ctx["token"], "AM_AUTOTEST_GAME_DIR": "/work/game", - "AM_AUTOTEST_HMC_DIR": "/work/hmc-cache", + "AM_AUTOTEST_HMC_CACHE_DIR": "/work/hmc-cache", }, mounts=[ (game_dir, "/work/game", False), - (ctx["cache_dir"], "/work/hmc-cache", False), + (hmc_cache_root, "/work/hmc-cache", False), ], command=[ "/opt/automodpack/run-headlessmc-client", diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile index 613244c93..061860dd4 100644 --- a/autotester/docker/client/Dockerfile +++ b/autotester/docker/client/Dockerfile @@ -29,10 +29,18 @@ RUN apt-get update \ openjdk-25-jre-headless \ && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache \ - && curl -fsSL "https://github.com/headlesshq/headlessmc/releases/download/${HEADLESSMC_VERSION}/headlessmc-launcher-linux-x64" \ - -o /usr/local/bin/hmc \ - && chmod +x /usr/local/bin/hmc +# Detect architecture at build time and fetch the matching HMC binary +RUN HMC_ARCH="$(uname -m)" && \ + case "$HMC_ARCH" in \ + x86_64) HMC_BIN="headlessmc-launcher-linux-x64" ;; \ + aarch64) HMC_BIN="headlessmc-launcher-linux-arm64" ;; \ + *) echo "Unsupported architecture: $HMC_ARCH"; exit 1 ;; \ + esac && \ + mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache && \ + curl -fsSL \ + "https://github.com/headlesshq/headlessmc/releases/download/${HEADLESSMC_VERSION}/${HMC_BIN}" \ + -o /usr/local/bin/hmc && \ + chmod +x /usr/local/bin/hmc COPY run-headlessmc-client /opt/automodpack/run-headlessmc-client RUN chmod +x /opt/automodpack/run-headlessmc-client diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 9569744bc..b083c4987 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -9,11 +9,14 @@ java_version="${5:?java version required}" loader_version="${6:-}" game_dir="${AM_AUTOTEST_GAME_DIR:-/work/game}" -hmc_dir="${AM_AUTOTEST_HMC_DIR:-/work/hmc-cache}" +cache_dir="${AM_AUTOTEST_HMC_CACHE_DIR:-/work/hmc-cache}" bridge_token="${AM_AUTOTEST_BRIDGE_TOKEN:?bridge token required}" -mkdir -p "$game_dir" "$hmc_dir" -mkdir -p "$hmc_dir/HeadlessMC" +mkdir -p "$game_dir" "$cache_dir" + +# Per-test HMC config dir — avoids write conflicts on shared cache bind-mount +config_dir=$(mktemp -d "/tmp/hmc-config-XXXXXX") +mkdir -p "$config_dir/HeadlessMC" # Select pre-installed Java - no HMC auto-download case "$java_version" in @@ -33,11 +36,13 @@ case "$java_version" in esac export PATH="$JAVA_HOME/bin:$PATH" +# ── Build JVM arguments ─────────────────────────────────────────── jvmargs="-Dautomodpack.autotest=true -Dautomodpack.autotest.gamedir=${game_dir} -Dautomodpack.autotest.token=${bridge_token} -Dcom.mojang.text2speech=false" gameargs="--width 854 --height 480" -cat > "$hmc_dir/HeadlessMC/config.properties" < "$config_dir/HeadlessMC/config.properties" </dev/null || true + launch_target="fabric-loader-${loader_version}-${minecraft}" fi +# ── Pre-download Minecraft assets (no-op if cached) ─────────────── echo n | hmc --command "download ${minecraft}" 2>/dev/null || true hmc --command "config -refresh" 2>/dev/null || true +# ── Launch ───────────────────────────────────────────────────────── exec hmc \ --command "launch ${launch_target} -lwjgl -offline --jvm \"${jvmargs}\" ${gameargs}" From f321649eb15bfae3e70c83fc8a62a8f65a7333da Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 23 May 2026 11:02:04 +0200 Subject: [PATCH 19/44] fix --- autotester/docker/client/Dockerfile | 3 +- .../docker/client/run-headlessmc-client | 49 ++++++++++--------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile index 061860dd4..a09b5f2cd 100644 --- a/autotester/docker/client/Dockerfile +++ b/autotester/docker/client/Dockerfile @@ -36,7 +36,8 @@ RUN HMC_ARCH="$(uname -m)" && \ aarch64) HMC_BIN="headlessmc-launcher-linux-arm64" ;; \ *) echo "Unsupported architecture: $HMC_ARCH"; exit 1 ;; \ esac && \ - mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache && \ + mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache /work/HeadlessMC && \ + chmod a+rwx /work/HeadlessMC && \ curl -fsSL \ "https://github.com/headlesshq/headlessmc/releases/download/${HEADLESSMC_VERSION}/${HMC_BIN}" \ -o /usr/local/bin/hmc && \ diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index b083c4987..e223e912e 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -14,34 +14,41 @@ bridge_token="${AM_AUTOTEST_BRIDGE_TOKEN:?bridge token required}" mkdir -p "$game_dir" "$cache_dir" -# Per-test HMC config dir — avoids write conflicts on shared cache bind-mount -config_dir=$(mktemp -d "/tmp/hmc-config-XXXXXX") -mkdir -p "$config_dir/HeadlessMC" +# ── Find the right Java binary ───────────────────────────────────── +java_bin="" +for dir in /usr/lib/jvm/*"${java_version}"*/bin/java; do + if [ -x "$dir" ]; then + java_bin="$dir" + break + fi +done -# Select pre-installed Java - no HMC auto-download -case "$java_version" in - 17) - export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" - ;; - 21) - export JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64" - ;; - 25) - export JAVA_HOME="/usr/lib/jvm/java-25-openjdk-amd64" - ;; - *) - echo "ERROR: unsupported Java version: $java_version" >&2 +if [ -z "$java_bin" ] && command -v java &>/dev/null; then + java_bin="$(command -v java)" +fi + +if [ -z "$java_bin" ] || [ ! -x "$java_bin" ]; then + echo "ERROR: Java ${java_version} not found" >&2 + echo "Searched /usr/lib/jvm/*${java_version}* and PATH" >&2 + ls -la /usr/lib/jvm/ 2>/dev/null || echo "(no /usr/lib/jvm)" >&2 exit 1 - ;; -esac +fi + +actual_ver=$("$java_bin" -version 2>&1 | head -1 | sed 's/.*version "\([0-9]*\).*/\1/') +echo "Java: $("$java_bin" -version 2>&1 | head -1) ($java_bin)" +if [ "$actual_ver" != "$java_version" ]; then + echo "WARNING: Requested Java ${java_version}, using Java ${actual_ver}" >&2 +fi + +export JAVA_HOME="${java_bin%/bin/java}" export PATH="$JAVA_HOME/bin:$PATH" # ── Build JVM arguments ─────────────────────────────────────────── jvmargs="-Dautomodpack.autotest=true -Dautomodpack.autotest.gamedir=${game_dir} -Dautomodpack.autotest.token=${bridge_token} -Dcom.mojang.text2speech=false" gameargs="--width 854 --height 480" -# ── Write HMC config to per-test directory ──────────────────────── -cat > "$config_dir/HeadlessMC/config.properties" < /work/HeadlessMC/config.properties < Date: Sat, 23 May 2026 11:30:38 +0200 Subject: [PATCH 20/44] more fixes --- autotester/automodpack_autotester/runner.py | 4 ++-- .../skidam/automodpack_core/modpack/ModpackContent.java | 2 +- .../automodpack/client/autotest/AutoTestBridge.java | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index ec6fa07ce..b36b6b9d7 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -337,8 +337,8 @@ def _launch_client(ctx, target, client_image): (game_dir / "mods").mkdir(parents=True, exist_ok=True) shutil.copy2(ctx["artifact"], game_dir / "mods" / "automodpack.jar") - # Mount the persistent HMC cache directly (no per-case copy) - hmc_cache_root = (ctx["out_dir"].parent / ".hmc-cache").resolve() + # Per-target HMC cache (isolated to prevent concurrent NeoForge installer corruption) + hmc_cache_root = (ctx["out_dir"].parent / ".hmc-cache" / target.id.replace(".", "_")).resolve() hmc_cache_root.mkdir(parents=True, exist_ok=True) _run_container( diff --git a/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java b/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java index bb9d67d86..810e97748 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java +++ b/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java @@ -89,7 +89,7 @@ public boolean create(FileMetadataCache cache) { } } - var tempPathMap = new HashMap<>(filesToProcess); + var tempPathMap = new ConcurrentHashMap<>(filesToProcess); List> futures = filesToProcess.entrySet().stream() .map(entry -> CompletableFuture.supplyAsync(() -> { diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java index 079fd31cc..0949b8ed3 100644 --- a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -234,8 +234,14 @@ private static Object widget(JsonObject req) { } private static String execOnMain(ThrowingSupplier t) throws Exception { + Minecraft c; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while ((c = Minecraft.getInstance()) == null) { + if (System.nanoTime() > deadline) return err("Minecraft not initialized"); + Thread.sleep(100); + } CompletableFuture f = new CompletableFuture<>(); - Minecraft.getInstance().execute(() -> { try { f.complete(t.get()); } catch (Exception e) { f.completeExceptionally(e); } }); + c.execute(() -> { try { f.complete(t.get()); } catch (Exception e) { f.completeExceptionally(e); } }); return f.get(60, TimeUnit.SECONDS); } From f5dd4abde5142f456966a3ea85a52d0df2a65a2b Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 23 May 2026 12:07:56 +0200 Subject: [PATCH 21/44] patch fml race condition --- autotester/docker/client/run-headlessmc-client | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index e223e912e..8a4a08106 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -68,6 +68,13 @@ if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then launch_target="fabric-loader-${loader_version}-${minecraft}" fi +# ── Workaround NeoForge race condition: FMLConfig.load() writes fml.toml ─ +# in a background thread, but the early-display thread reads it before +# the defaults are written — missing key → auto-unbox NPE. +# Pre-seed the file so the key exists regardless of thread ordering. +mkdir -p "$game_dir/config" +echo "debugOpenGl = false" > "$game_dir/config/fml.toml" + # ── Pre-download Minecraft assets (no-op if cached) ─────────────── echo n | hmc --command "download ${minecraft}" 2>/dev/null || true hmc --command "config -refresh" 2>/dev/null || true From 78cd77b68b09e843039c92cb4cb5e368c0c7a3d8 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 23 May 2026 12:24:00 +0200 Subject: [PATCH 22/44] just disable the problematic early window --- autotester/docker/client/run-headlessmc-client | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 8a4a08106..831474a49 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -68,12 +68,12 @@ if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then launch_target="fabric-loader-${loader_version}-${minecraft}" fi -# ── Workaround NeoForge race condition: FMLConfig.load() writes fml.toml ─ -# in a background thread, but the early-display thread reads it before -# the defaults are written — missing key → auto-unbox NPE. -# Pre-seed the file so the key exists regardless of thread ordering. +# ── Disable NeoForge early display window (crashes in headless CI) ── +# The early-loading screen serves no purpose on a headless server and +# has a race condition: initRender reads FMLConfig before FMLConfig.load() +# finishes writing defaults, causing a null → auto-unbox NPE. mkdir -p "$game_dir/config" -echo "debugOpenGl = false" > "$game_dir/config/fml.toml" +echo "earlyWindowControl = false" > "$game_dir/config/fml.toml" # ── Pre-download Minecraft assets (no-op if cached) ─────────────── echo n | hmc --command "download ${minecraft}" 2>/dev/null || true From cd49bbbbcd4c57c7892efa2215cead8e902f02b9 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 23 May 2026 14:11:28 +0200 Subject: [PATCH 23/44] use fml11 for neoforge 26.1+ and update deps --- .../docker/client/run-headlessmc-client | 10 +-- buildSrc/src/main/kotlin/ModuleUtils.kt | 3 +- gradle.properties | 2 +- loader/loader-neoforge.gradle.kts | 7 +- loader/neoforge/fml10/gradle.properties | 3 +- loader/neoforge/fml11/gradle.properties | 2 + .../EarlyModLocator.java | 38 +++++++++++ .../loader/LoaderManager.java | 67 +++++++++++++++++++ .../mods/ModpackLoader.java | 40 +++++++++++ .../resources/META-INF/jarjar/metadata.json | 12 ++++ .../resources/META-INF/neoforge.mods.toml | 14 ++++ ...forgespi.locating.IModFileCandidateLocator | 1 + loader/neoforge/fml4/gradle.properties | 3 +- settings.gradle.kts | 2 +- stonecutter.gradle.kts | 6 +- stonecutter.properties.toml | 16 ++--- 16 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 loader/neoforge/fml11/gradle.properties create mode 100644 loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java create mode 100644 loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java create mode 100644 loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java create mode 100644 loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json create mode 100644 loader/neoforge/fml11/src/main/resources/META-INF/neoforge.mods.toml create mode 100644 loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 831474a49..d96dc3dd6 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -68,12 +68,12 @@ if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then launch_target="fabric-loader-${loader_version}-${minecraft}" fi -# ── Disable NeoForge early display window (crashes in headless CI) ── -# The early-loading screen serves no purpose on a headless server and -# has a race condition: initRender reads FMLConfig before FMLConfig.load() -# finishes writing defaults, causing a null → auto-unbox NPE. +# ── Disable Neo/Forge early display window (may cause crash in CI) ── mkdir -p "$game_dir/config" -echo "earlyWindowControl = false" > "$game_dir/config/fml.toml" +cat > "$game_dir/config/fml.toml" </dev/null || true diff --git a/buildSrc/src/main/kotlin/ModuleUtils.kt b/buildSrc/src/main/kotlin/ModuleUtils.kt index 4ad5c0e6f..a0420e930 100644 --- a/buildSrc/src/main/kotlin/ModuleUtils.kt +++ b/buildSrc/src/main/kotlin/ModuleUtils.kt @@ -11,7 +11,8 @@ fun getLoaderModuleName(name: String): String { name.contains("fabric") -> "fabric-core" name.contains("neoforge") -> when (mcVersion) { "1.21.8", "1.21.5", "1.21.4", "1.21.1" -> "neoforge-fml4" - "1.21.11", "1.21.10", "26.1" -> "neoforge-fml10" + "1.21.10", "1.21.11" -> "neoforge-fml10" + "26.1" -> "neoforge-fml11" else -> error("Unknown neoforge loader module for Minecraft version: $mcVersion") } name.contains("forge") -> if (mcVersion == "1.18.2") "forge-fml40" else "forge-fml47" diff --git a/gradle.properties b/gradle.properties index adb375245..5cd13678c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ org.gradle.caching = true org.gradle.configuration-cache.problems = warn org.gradle.daemon = true -core_modules = core, fabric-core, fabric-15, fabric-16, forge-fml40, forge-fml47, neoforge-fml4, neoforge-fml10 +core_modules = core, fabric-core, fabric-15, fabric-16, forge-fml40, forge-fml47, neoforge-fml4, neoforge-fml10, neoforge-fml11 mod.id = automodpack_mod mod_name = AutoModpack diff --git a/loader/loader-neoforge.gradle.kts b/loader/loader-neoforge.gradle.kts index 07819bcba..881cb5bee 100644 --- a/loader/loader-neoforge.gradle.kts +++ b/loader/loader-neoforge.gradle.kts @@ -82,9 +82,10 @@ tasks.named("shadowJar") { } java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + val javaVersion = findProperty("deps.java") as String + sourceCompatibility = JavaVersion.toVersion(javaVersion) + targetCompatibility = JavaVersion.toVersion(javaVersion) + toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion)) withSourcesJar() } diff --git a/loader/neoforge/fml10/gradle.properties b/loader/neoforge/fml10/gradle.properties index 3bfd31f4a..116805422 100644 --- a/loader/neoforge/fml10/gradle.properties +++ b/loader/neoforge/fml10/gradle.properties @@ -1 +1,2 @@ -deps.neoforge=21.10.64 \ No newline at end of file +deps.neoforge=21.10.64 +deps.java=21 \ No newline at end of file diff --git a/loader/neoforge/fml11/gradle.properties b/loader/neoforge/fml11/gradle.properties new file mode 100644 index 000000000..9f0e70b27 --- /dev/null +++ b/loader/neoforge/fml11/gradle.properties @@ -0,0 +1,2 @@ +deps.neoforge = 26.1.2.64-beta +deps.java = 25 diff --git a/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java new file mode 100644 index 000000000..e88cdd392 --- /dev/null +++ b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java @@ -0,0 +1,38 @@ +package pl.skidam.automodpack_loader_core_neoforge; + +import net.neoforged.fml.jarcontents.JarContents; +import net.neoforged.fml.loading.progress.ProgressMeter; +import net.neoforged.fml.loading.progress.StartupNotificationManager; +import net.neoforged.neoforgespi.ILaunchContext; +import net.neoforged.neoforgespi.locating.*; +import pl.skidam.automodpack_loader_core.Preload; +import pl.skidam.automodpack_loader_core_neoforge.mods.ModpackLoader; + +import java.nio.file.Path; + +@SuppressWarnings("unused") +public class EarlyModLocator implements IModFileCandidateLocator { + + @Override + public void findCandidates(ILaunchContext context, IDiscoveryPipeline pipeline) { + + ProgressMeter progress = StartupNotificationManager.prependProgressBar("[Automodpack] Preload", 0); + new Preload(); + progress.complete(); + + for (Path path : ModpackLoader.modsToLoad) { + pipeline.addPath(path, ModFileDiscoveryAttributes.DEFAULT, IncompatibleFileReporting.WARN_ALWAYS); + try { + JarContents jarContents = JarContents.ofPath(path); + pipeline.readModFile(jarContents, ModFileDiscoveryAttributes.DEFAULT); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + @Override + public int getPriority() { + return IModFileCandidateLocator.HIGHEST_SYSTEM_PRIORITY; + } +} \ No newline at end of file diff --git a/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java new file mode 100644 index 000000000..77d4c2918 --- /dev/null +++ b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java @@ -0,0 +1,67 @@ +package pl.skidam.automodpack_loader_core_neoforge.loader; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.loading.FMLLoader; +import net.neoforged.fml.loading.LoadingModList; +import net.neoforged.fml.loading.moddiscovery.ModInfo; +import pl.skidam.automodpack_core.loader.LoaderManagerService; + +import static pl.skidam.automodpack_core.Constants.*; + +@SuppressWarnings("unused") +public class LoaderManager implements LoaderManagerService { + + @Override + public ModPlatform getPlatformType() { + return ModPlatform.NEOFORGE; + } + + @Override + public boolean isModLoaded(String modId) { + LoadingModList loadingModList; + try { + loadingModList= FMLLoader.getCurrent().getLoadingModList(); + } catch (IllegalStateException e) { + return false; + } + return loadingModList.getModFileById(modId) != null; + } + + @Override + public String getLoaderVersion() { + return FMLLoader.getCurrent().getVersionInfo().neoForgeVersion(); + } + + @Override + public EnvironmentType getEnvironmentType() { + if (FMLLoader.getCurrent().getDist() == Dist.CLIENT) { + return EnvironmentType.CLIENT; + } else { + return EnvironmentType.SERVER; + } + } + + @Override + public String getModVersion(String modId) { + if (preload) { + if (modId.equals("minecraft")) { + return FMLLoader.getCurrent().getVersionInfo().mcVersion(); + } + + return null; + } + + ModInfo modInfo = FMLLoader.getCurrent().getLoadingModList().getMods().stream().filter(mod -> mod.getModId().equals(modId)).findFirst().orElse(null); + + if (modInfo == null) { + return null; + } + + return modInfo.getVersion().toString(); + } + + @Override + public boolean isDevelopmentEnvironment() { + return !FMLLoader.getCurrent().isProduction(); + } +} \ No newline at end of file diff --git a/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java new file mode 100644 index 000000000..2dc4f71be --- /dev/null +++ b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java @@ -0,0 +1,40 @@ +package pl.skidam.automodpack_loader_core_neoforge.mods; + +import pl.skidam.automodpack_core.loader.ModpackLoaderService; +import pl.skidam.automodpack_core.utils.FileInspection; +import pl.skidam.automodpack_core.utils.cache.FileMetadataCache; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static pl.skidam.automodpack_core.Constants.LOGGER; + +public class ModpackLoader implements ModpackLoaderService { + public static String CONNECTOR_MODS_PROPERTY = "connector.additionalModLocations"; + public static List modsToLoad = new ArrayList<>(); + + @Override + public void loadModpack(List modpackMods) { + try { + for (Path modpackMod : modpackMods) { + if (FileInspection.isModCompatible(modpackMod)) { + modsToLoad.add(modpackMod); + } + } + + // set for connector + String paths = modpackMods.stream().map(Path::toString).collect(Collectors.joining(",")); + String finalMods = paths + "," + System.getProperty(CONNECTOR_MODS_PROPERTY, ""); + System.setProperty(CONNECTOR_MODS_PROPERTY, finalMods); + } catch (Exception e) { + LOGGER.error("Error while loading modpack", e); + } + } + + @Override + public List getModpackNestedConflicts(Path modpackDir, FileMetadataCache cache) { + return new ArrayList<>(); + } +} diff --git a/loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json b/loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json new file mode 100644 index 000000000..975276778 --- /dev/null +++ b/loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json @@ -0,0 +1,12 @@ +{ + "jars": [ + { + "identifier": { + "group": "pl.skidam", + "artifact": "automodpack" + }, + "path": "META-INF/jarjar/automodpack-mod.jar", + "isObfuscated": false + } + ] +} diff --git a/loader/neoforge/fml11/src/main/resources/META-INF/neoforge.mods.toml b/loader/neoforge/fml11/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 000000000..00d2a7044 --- /dev/null +++ b/loader/neoforge/fml11/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,14 @@ +modLoader = "javafml" +loaderVersion = "[1,)" +license = "LGPLv3" + +[[mods]] +modId = "automodpack" +version = "${version}" +displayName = "AutoModpack" +displayURL = "https://modrinth.com/mod/automodpack" +authors = "Skidam" +description = ''' + +''' +logoFile = "icon.png" diff --git a/loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator b/loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator new file mode 100644 index 000000000..f5484f137 --- /dev/null +++ b/loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator @@ -0,0 +1 @@ +pl.skidam.automodpack_loader_core.EarlyModLocator diff --git a/loader/neoforge/fml4/gradle.properties b/loader/neoforge/fml4/gradle.properties index cc919d069..23cf9f4e2 100644 --- a/loader/neoforge/fml4/gradle.properties +++ b/loader/neoforge/fml4/gradle.properties @@ -1 +1,2 @@ -deps.neoforge=21.1.182 \ No newline at end of file +deps.neoforge=21.1.230 +deps.java=21 diff --git a/settings.gradle.kts b/settings.gradle.kts index c9b1ea74a..d0f318f00 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,7 @@ coreModules.forEach { module -> "fabric-core" -> project.buildFileName = "../../loader-fabric-core.gradle.kts" "fabric-15", "fabric-16" -> project.buildFileName = "../../loader-fabric.gradle.kts" "forge-fml40", "forge-fml47" -> project.buildFileName = "../../loader-forge.gradle.kts" - "neoforge-fml4", "neoforge-fml10" -> project.buildFileName = "../../loader-neoforge.gradle.kts" + "neoforge-fml4", "neoforge-fml10", "neoforge-fml11" -> project.buildFileName = "../../loader-neoforge.gradle.kts" } } diff --git a/stonecutter.gradle.kts b/stonecutter.gradle.kts index c852ceb9e..5164b069f 100644 --- a/stonecutter.gradle.kts +++ b/stonecutter.gradle.kts @@ -1,10 +1,10 @@ plugins { id("dev.kikugie.stonecutter") kotlin("jvm") version "2.3.0" apply false - id("net.fabricmc.fabric-loom-remap") version "1.15-SNAPSHOT" apply false - id("net.fabricmc.fabric-loom") version "1.15-SNAPSHOT" apply false + id("net.fabricmc.fabric-loom-remap") version "1.16-SNAPSHOT" apply false + id("net.fabricmc.fabric-loom") version "1.16-SNAPSHOT" apply false id("net.neoforged.moddev") version "2.0.141" apply false - id("com.gradleup.shadow") version "9.4.0" apply false + id("com.gradleup.shadow") version "9.4.1" apply false id("org.moddedmc.wiki.toolkit") version "0.4+" } diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index 58eb02150..3b310e8f0 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -11,22 +11,22 @@ publish_versions = "26.2" ["26.1-fabric"] -deps.fabric-api = "0.144.0+26.1" +deps.fabric-api = "0.149.1+26.1.2" ["26.1-neoforge"] -deps.neoforge = "26.1.0.1-beta" +deps.neoforge = "26.1.2.64-beta" ["26.1"] deps.minecraft = "26.1" -meta.minecraft = "26.1" -publish_versions = "26.1" +meta.minecraft = "~26.1" +publish_versions = "26.1\n26.1.1\n26.1.2" ["1.21.11-fabric"] -deps.fabric-api = "0.141.1+1.21.11" +deps.fabric-api = "0.141.4+1.21.11" ["1.21.11-neoforge"] -deps.neoforge = "21.11.8-beta" +deps.neoforge = "21.11.42" ["1.21.11"] deps.minecraft = "1.21.11" @@ -83,10 +83,10 @@ publish_versions = "1.21.4" ["1.21.1-fabric"] -deps.fabric-api = "0.116.7+1.21.1" +deps.fabric-api = "0.116.12+1.21.1" ["1.21.1-neoforge"] -deps.neoforge = "21.1.169" +deps.neoforge = "21.1.230" ["1.21.1"] deps.minecraft = "1.21.1" From 3bbb6281a1f007e7b6619e9eae74b30eefeb5109 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 23 May 2026 15:10:27 +0200 Subject: [PATCH 24/44] cache, job, key interruption, reliability fixes --- autotester/README.md | 2 +- autotester/automodpack_autotester/cli.py | 11 +++++++---- autotester/docker/client/Dockerfile | 4 ++-- autotester/docker/client/run-headlessmc-client | 16 ++++++++++++---- autotester/scenarios/sync.yaml | 5 ++--- autotester/settings.yaml | 2 +- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/autotester/README.md b/autotester/README.md index eae11cf0b..0de022c05 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -277,7 +277,7 @@ images: run: target: all # Default --target scenario: sync # Default --scenario - jobs: 4 # Default --jobs + jobs: 1 # Default --jobs retryMax: 0 # Not implemented (reserved) server: diff --git a/autotester/automodpack_autotester/cli.py b/autotester/automodpack_autotester/cli.py index 786bed416..c82548ad2 100644 --- a/autotester/automodpack_autotester/cli.py +++ b/autotester/automodpack_autotester/cli.py @@ -162,7 +162,11 @@ def main(argv: list[str] | None = None) -> int: print("\nInterrupted, cleaning up containers...", file=sys.stderr) for ff in task_map: ff.cancel() - _kill_amp_containers() + try: + _kill_amp_containers() + except KeyboardInterrupt: + print("Force exit.", file=sys.stderr) + os._exit(1) print("Cleanup complete.", file=sys.stderr) finally: @@ -172,9 +176,8 @@ def main(argv: list[str] | None = None) -> int: json.dumps({"ok": ok, "results": list(results.values())}, indent=2) ) if interrupted: - return 1 + os._exit(1) return 0 if ok else 1 except KeyboardInterrupt: - print("Force exit.", file=sys.stderr) - return 1 + os._exit(1) diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile index a09b5f2cd..f0f393c49 100644 --- a/autotester/docker/client/Dockerfile +++ b/autotester/docker/client/Dockerfile @@ -36,8 +36,8 @@ RUN HMC_ARCH="$(uname -m)" && \ aarch64) HMC_BIN="headlessmc-launcher-linux-arm64" ;; \ *) echo "Unsupported architecture: $HMC_ARCH"; exit 1 ;; \ esac && \ - mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache /work/HeadlessMC && \ - chmod a+rwx /work/HeadlessMC && \ + mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache /work/HeadlessMC /work/hmc-shared-versions && \ + chmod a+rwx /work/HeadlessMC /work/hmc-shared-versions && \ curl -fsSL \ "https://github.com/headlesshq/headlessmc/releases/download/${HEADLESSMC_VERSION}/${HMC_BIN}" \ -o /usr/local/bin/hmc && \ diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index d96dc3dd6..84b55ba22 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -14,6 +14,14 @@ bridge_token="${AM_AUTOTEST_BRIDGE_TOKEN:?bridge token required}" mkdir -p "$game_dir" "$cache_dir" +# ── Share client-jar cache across targets ────────────────────────────── +shared_versions="/work/hmc-shared-versions" +mkdir -p "$shared_versions" +if [ ! -L "$cache_dir/versions" ]; then + rm -rf "$cache_dir/versions" 2>/dev/null || true + ln -s "$shared_versions" "$cache_dir/versions" +fi + # ── Find the right Java binary ───────────────────────────────────── java_bin="" for dir in /usr/lib/jvm/*"${java_version}"*/bin/java; do @@ -64,7 +72,7 @@ EOF # ── Install Fabric loader if needed ─────────────────────────────── launch_target="${loader}:${minecraft}" if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then - hmc --command "fabric ${minecraft} --uid ${loader_version}" 2>/dev/null || true + timeout 30 hmc --command "fabric ${minecraft} --uid ${loader_version}" 2>/dev/null || true launch_target="fabric-loader-${loader_version}-${minecraft}" fi @@ -75,10 +83,10 @@ earlyWindowControl = false splashscreen = false EOF -# ── Pre-download Minecraft assets (no-op if cached) ─────────────── -echo n | hmc --command "download ${minecraft}" 2>/dev/null || true +# ── Pre-download Minecraft assets (best-effort, with timeout) ───── +timeout 20 hmc --command "download ${minecraft}" 2>/dev/null || true hmc --command "config -refresh" 2>/dev/null || true # ── Launch ───────────────────────────────────────────────────────── -exec hmc \ +exec timeout 90 hmc \ --command "launch ${launch_target} -lwjgl -offline --jvm \"${jvmargs}\" ${gameargs}" diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index c9c71fe5a..70fd803fd 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -15,7 +15,7 @@ # read_fingerprint Extract TLS certificate fingerprint from server logs. # launch_client Start/restart the client container (HeadlessMC). # wait_bridge Poll bridge-state.json, ping the bridge TCP handler. -# click_continue Click "Continue" on Forge/NeoForge migration screens. +# click_continue Click "Continue" on minecraft welcome screen. # connect Bridge: connect to server at {srv_name}:25565. # wait_fingerprint Wait until the client opens FingerprintVerificationScreen. # accept_fingerprint Type fingerprint into EditBox, click Verify. @@ -82,7 +82,7 @@ flow: - read_fingerprint # extract TLS fingerprint from server logs - wait_server # wait for "Done (" — runs concurrent with client boot - wait_bridge # poll bridge-state.json, ping bridge - - click_continue # click Continue on welcome screens + - click_continue # click Continue on welcome screen - connect # bridge.connect(host, port) - wait_fingerprint # wait for FingerprintVerificationScreen - accept_fingerprint # type fingerprint in EditBox → click Verify @@ -94,7 +94,6 @@ flow: - quit # bridge.quit() → wait for client exit - launch_client # fresh client container (rejoin) - wait_bridge # wait for bridge on new client - - click_continue # click Continue on welcome screens - connect # connect to server - wait_join # poll null screenClass (in-game) - quit # quit diff --git a/autotester/settings.yaml b/autotester/settings.yaml index 2351f4339..97cbefc4f 100644 --- a/autotester/settings.yaml +++ b/autotester/settings.yaml @@ -11,7 +11,7 @@ images: run: target: all scenario: sync - jobs: 4 + jobs: 1 retryMax: 0 server: From 6de75de6a1205b8cc575591e5a40b6155f77c8b2 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 23 May 2026 18:30:25 +0200 Subject: [PATCH 25/44] always ensure_ready otherwise we might crash due to trying to connect to a server before we even load all of the minecraft assets --- autotester/automodpack_autotester/runner.py | 4 ++-- autotester/scenarios/download-only.yaml | 2 +- autotester/scenarios/sync.yaml | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index b36b6b9d7..4ea42d1cb 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -404,8 +404,8 @@ def _phase_wait_bridge(ctx): raise TimeoutError(f"Bridge for {ctx['target'].id} did not become available within {to}s") -@_reg("click_continue") -def _phase_click_continue(ctx): +@_reg("ensure_ready") +def _phase_ensure_ready(ctx): bridge = ctx["bridge"] dl = time.monotonic() + 30 while time.monotonic() < dl: diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml index c951d7a96..5be57a5e2 100644 --- a/autotester/scenarios/download-only.yaml +++ b/autotester/scenarios/download-only.yaml @@ -17,7 +17,7 @@ flow: - read_fingerprint - wait_server - wait_bridge - - click_continue + - ensure_ready - connect - wait_fingerprint - accept_fingerprint diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index 70fd803fd..1f0fa1cbe 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -15,7 +15,7 @@ # read_fingerprint Extract TLS certificate fingerprint from server logs. # launch_client Start/restart the client container (HeadlessMC). # wait_bridge Poll bridge-state.json, ping the bridge TCP handler. -# click_continue Click "Continue" on minecraft welcome screen. +# ensure_ready Wait and/or click through "Continue" till title screen appears. # connect Bridge: connect to server at {srv_name}:25565. # wait_fingerprint Wait until the client opens FingerprintVerificationScreen. # accept_fingerprint Type fingerprint into EditBox, click Verify. @@ -82,7 +82,7 @@ flow: - read_fingerprint # extract TLS fingerprint from server logs - wait_server # wait for "Done (" — runs concurrent with client boot - wait_bridge # poll bridge-state.json, ping bridge - - click_continue # click Continue on welcome screen + - ensure_ready # wait till title scrren and click through continue on welcome screen - connect # bridge.connect(host, port) - wait_fingerprint # wait for FingerprintVerificationScreen - accept_fingerprint # type fingerprint in EditBox → click Verify @@ -94,6 +94,7 @@ flow: - quit # bridge.quit() → wait for client exit - launch_client # fresh client container (rejoin) - wait_bridge # wait for bridge on new client + - ensure_ready # wait till title scrren appears - connect # connect to server - wait_join # poll null screenClass (in-game) - quit # quit From dd2f247c5d4fff6440be1dec036e118789e82fb3 Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 27 May 2026 17:26:36 +0200 Subject: [PATCH 26/44] fixes --- autotester/automodpack_autotester/runner.py | 6 ++++++ autotester/docker/client/run-headlessmc-client | 3 ++- autotester/settings.yaml | 1 + autotester/targets.yaml | 4 ++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index 4ea42d1cb..017d58490 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -349,6 +349,12 @@ def _launch_client(ctx, target, client_image): "AM_AUTOTEST_BRIDGE_TOKEN": ctx["token"], "AM_AUTOTEST_GAME_DIR": "/work/game", "AM_AUTOTEST_HMC_CACHE_DIR": "/work/hmc-cache", + "AM_AUTOTEST_CLIENT_TIMEOUT_SECONDS": str( + int(float(ctx["scenario"].get("timeouts", {}).get( + "clientRunSeconds", + ctx["settings"].get("timeouts", {}).get("clientRunSeconds", 600), + ))) + ), }, mounts=[ (game_dir, "/work/game", False), diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 84b55ba22..cb8a1a740 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -11,6 +11,7 @@ loader_version="${6:-}" game_dir="${AM_AUTOTEST_GAME_DIR:-/work/game}" cache_dir="${AM_AUTOTEST_HMC_CACHE_DIR:-/work/hmc-cache}" bridge_token="${AM_AUTOTEST_BRIDGE_TOKEN:?bridge token required}" +client_timeout="${AM_AUTOTEST_CLIENT_TIMEOUT_SECONDS:-600}" mkdir -p "$game_dir" "$cache_dir" @@ -88,5 +89,5 @@ timeout 20 hmc --command "download ${minecraft}" 2>/dev/null || true hmc --command "config -refresh" 2>/dev/null || true # ── Launch ───────────────────────────────────────────────────────── -exec timeout 90 hmc \ +exec timeout "$client_timeout" hmc \ --command "launch ${launch_target} -lwjgl -offline --jvm \"${jvmargs}\" ${gameargs}" diff --git a/autotester/settings.yaml b/autotester/settings.yaml index 97cbefc4f..a1321a54b 100644 --- a/autotester/settings.yaml +++ b/autotester/settings.yaml @@ -41,6 +41,7 @@ headlessmc: timeouts: serverStartSeconds: 180 clientStartSeconds: 180 + clientRunSeconds: 300 downloadFileSeconds: 180 rejoinSeconds: 90 diff --git a/autotester/targets.yaml b/autotester/targets.yaml index 7f884be3d..4e6df4c72 100644 --- a/autotester/targets.yaml +++ b/autotester/targets.yaml @@ -4,9 +4,9 @@ defaults: targets: - { id: "26.1-fabric", minecraft: "26.1", loader: "fabric", java: 25, fabricLoader: "0.18.4" } - - { id: "26.1-neoforge", minecraft: "26.1", loader: "neoforge", java: 25, neoforgeVersion: "26.1.0.1-beta" } + - { id: "26.1-neoforge", minecraft: "26.1", loader: "neoforge", java: 25, neoforgeVersion: "26.1.2.64-beta" } - { id: "1.21.11-fabric", minecraft: "1.21.11", loader: "fabric", java: 21, fabricLoader: "0.17.3" } - - { id: "1.21.11-neoforge", minecraft: "1.21.11", loader: "neoforge", java: 21, neoforgeVersion: "21.11.37-beta" } + - { id: "1.21.11-neoforge", minecraft: "1.21.11", loader: "neoforge", java: 21, neoforgeVersion: "21.11.42" } - { id: "1.21.10-fabric", minecraft: "1.21.10", loader: "fabric", java: 21, fabricLoader: "0.17.3" } - { id: "1.21.10-neoforge", minecraft: "1.21.10", loader: "neoforge", java: 21, neoforgeVersion: "21.10.64" } - { id: "1.21.8-fabric", minecraft: "1.21.8", loader: "fabric", java: 21, fabricLoader: "0.17.3" } From cc30f928b8efd0987a1b03617e41d204d980da97 Mon Sep 17 00:00:00 2001 From: skidam Date: Wed, 27 May 2026 18:00:46 +0200 Subject: [PATCH 27/44] Prepare autotester PR for upstream --- autotester/README.md | 497 +++++------------- autotester/scenarios/download-only.yaml | 11 +- autotester/scenarios/sync.yaml | 119 +---- .../protocol/DownloadClient.java | 19 +- .../client/ModpackUtils.java | 99 ++-- .../client/ClientLoginNetworkAddon.java | 20 +- .../networking/packet/DataC2SPacket.java | 43 +- 7 files changed, 267 insertions(+), 541 deletions(-) diff --git a/autotester/README.md b/autotester/README.md index 0de022c05..b299029ed 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -1,422 +1,185 @@ # AutoModpack Autotester -Docker-based in-game integration tests for AutoModpack. Spins up a real -Minecraft server + client, runs the full modpack sync flow, and verifies -everything works. - -## How it works - -Every test is defined by a **scenario** — a YAML file that lists **phases** -to execute in order. The framework orchestrates Docker containers -(server via itzg/minecraft-server, client via HeadlessMC) and drives -the in-game UI through a file-based JSON bridge. - -The default scenario (`sync`) launches a server, starts a client, -runs the sync flow (fingerprint → download → verify → restart), -then launches a second client session to verify rejoin behaves correctly -(no re-download, player joins in-game immediately). - -For each target, `run_case()` in `runner.py` creates an isolated Docker -network, starts a server + client container, executes the phase sequence, -captures logs, removes the containers, and returns a result dict that is -aggregated and written to `results.json`. +Docker-based in-game integration tests for AutoModpack. The runner starts a +real Minecraft server and a HeadlessMC client, drives the client UI through an +opt-in file bridge, and verifies that the modpack sync flow works end to end. ## Prerequisites -- Docker (tested with 27+) -- Python >= 3.11 + [uv](https://docs.astral.sh/uv/) -- Merged AutoModpack artifact in `merged/` (produced by `./gradlew mergeJar`) - -## CLI reference +- Docker +- Python 3.11+ +- `uv` +- Built AutoModpack artifacts in `merged/` -All commands run from the repo root: +Build artifacts first from the repository root: +```bash +./gradlew mergeJar ``` -uv --project autotester run autotester build-images [--client-image IMG] [--headlessmc-version VER] -uv --project autotester run autotester run [--target ID | all] [--scenario ID] [--jobs N] - [--docker-uid UID] [--docker-gid GID] - [--artifact-dir PATH] [--out-dir PATH] - [--client-image IMG] -uv --project autotester run autotester clean [--out-dir PATH] -``` - -(Or `cd autotester` and use `uv run autotester ...` instead.) -### build-images - -Builds the client Docker image (Java + HeadlessMC). - -| Flag | Default | Description | -|------|---------|-------------| -| `--client-image` | `settings.yaml → images.client` | Tag for the built image | -| `--headlessmc-version` | `settings.yaml → headlessmc.version` | HeadlessMC launcher version | -### run +## Quick Start -Runs the selected target(s) against a scenario. +Build the client image: -| Flag | Default | Description | -|------|---------|-------------| -| `--target` | `settings.yaml → run.target` (`all`) | Target ID from `targets.yaml`, or `all` | -| `--scenario` | `settings.yaml → run.scenario` (`sync`) | Scenario name (stem of `scenarios/*.yaml`) | -| `--jobs` | `settings.yaml → run.jobs` (`1`) | Max parallel containers (watch Docker resources) | -| `--docker-uid` | `AUTOTEST_DOCKER_UID` or `os.getuid()` | UID for client container process | -| `--docker-gid` | `AUTOTEST_DOCKER_GID` or `os.getgid()` | GID for client container process | -| `--artifact-dir` | `settings.yaml → paths.artifactDir` (`merged/`) | Directory with merged loader JARs | -| `--out-dir` | `settings.yaml → paths.outDir` (`autotester/out/`) | Output directory for logs and results | -| `--client-image` | `settings.yaml → images.client` | Client Docker image tag | +```bash +uv --project autotester run autotester build-images +``` -**Ctrl+C behavior:** -- First Ctrl+C cancels queued (not-yet-started) tests, waits for running - containers to be cleaned up by their `finally` blocks, then writes partial - `results.json` and exits with code 1. -- Second Ctrl+C calls `os._exit(1)` immediately (force kill). +Run one target: -### clean +```bash +uv --project autotester run autotester run --target 1.21.11-fabric --scenario download-only +``` -Removes the output directory entirely. +Run the full default matrix: -## Output layout +```bash +uv --project autotester run autotester run --target all --scenario sync --jobs 1 +``` -Each run produces a timestamped directory under `--out-dir`: +Clean generated output: -``` -/ -├── results.json ← aggregated results (see below) -├── --/ ← one per test case (run_case) -│ ├── amp-s-.log ← server container logs -│ ├── amp-c-.log ← client container logs -│ ├── server/ ← server game directory -│ │ ├── mods/automodpack.jar -│ │ ├── automodpack/automodpack-server.json -│ │ ├── automodpack/host-modpack/main/config/amp-autotest-marker.json -│ │ └── automodpack/host-modpack/main/config/amp-autotest-*.txt -│ └── client/ -│ └── game/ ← client game directory -│ ├── mods/automodpack.jar -│ ├── config/fml.toml ← Forge/NeoForge only -│ └── automodpack/ -│ ├── autotest/ ← bridge command/response files -│ └── modpacks/ -│ └── amp-autotest/ ← synced modpack (downloaded) -└── .hmc-cache/ ← seeded HMC cache (persists between runs) +```bash +uv --project autotester run autotester clean ``` -### results.json schema +## What It Tests -Written when `run` completes (or after Ctrl+C). Structure: +The default `sync` scenario performs this flow: -```json -{ - "ok": false, - "results": [ - { - "target": "1.21.11-fabric", - "scenario": "sync", - "ok": false, - "duration": 142.7, - "error": "Timeout: marker file ... did not appear within 180s" - }, - { - "target": "1.21.11-neoforge", - "scenario": "sync", - "ok": true, - "duration": 98.3 - } - ] -} -``` +1. Start a server container. +2. Start a client container. +3. Connect to the server. +4. Accept the server certificate fingerprint. +5. Download and verify synced files. +6. Restart the client. +7. Rejoin and verify the player reaches the game. -| Field | Type | Description | -|-------|------|-------------| -| `ok` | bool | `true` only if ALL results are ok | -| `results[].target` | string | Target ID from `targets.yaml` | -| `results[].scenario` | string | Scenario ID | -| `results[].ok` | bool | Did this test pass? | -| `results[].duration` | float | Wall-clock seconds for `run_case()` | -| `results[].error` | string? | Failure message (absent on pass) | - -**Exit codes:** `0` = all passed, `1` = any failed or user interrupted. - -## Phase reference - -| Phase | Description | Timeout | -|-------|-------------|---------| -| `launch_server` | Start the server container (itzg/minecraft-server) | N/A | -| `launch_client` | Start/restart client container (HeadlessMC); removes existing container first if re-running | N/A | -| `read_fingerprint` | Extract TLS fingerprint from server logs (regex `certificate fingerprint[: ]+[0-9A-Fa-f:]+`) | `serverStartSeconds` (default 180s) | -| `wait_server` | Wait for `Done (` in server logs | `serverStartSeconds` (default 180s) | -| `wait_bridge` | Poll `bridge-state.json` for existence, then ping the bridge | `clientStartSeconds` (default 180s) | -| `click_continue` | Click "Continue" on Forge/NeoForge welcome/snooper screens until TitleScreen appears | 30s | -| `connect` | Bridge `connect` to server; retries on failure (TitleScreen → retry, ConnectScreen stuck → retry, other → success) | 90s | -| `wait_fingerprint` | Wait for `FingerprintVerificationScreen` to appear | 180s | -| `accept_fingerprint` | Type fingerprint into EditBox, click Verify, wait for DangerScreen/DownloadScreen/RestartScreen | 20s | -| `skip_fingerprint` | Skip verification (for versions without EditBox): click Skip, type "I accept the risk", click active Skip button | 30s | -| `wait_danger` | Wait for `DangerScreen` to appear | 90s | -| `click_confirm` | Click the last active Button on current screen (Confirm on DangerScreen) | 5s | -| `wait_download` | Wait for modpack marker file to appear under `modpacks/{name}/` | `downloadFileSeconds` (default 180s) | -| `verify_files` | Check all `serverFiles.files[]` exist under synced modpack | 120s | -| `verify_mods` | Check all `serverFiles.expectedMods[]` glob patterns match installed jars | 120s | -| `click_restart` | If RestartScreen appears within 20s, click Restart/Close/Quit button, wait for client exit | wait_exit: 90s | -| `quit` | If container is running, bridge `quit` command | bridge: 30s | -| `wait_join` | Poll for null `screenClass` (player in-game, no modal screens) | `rejoinSeconds` (default 90s) | - -## Bridge protocol - -The autotester drives Minecraft's UI through a file-based JSON bridge. -The bridge reads commands from a file and writes responses. - -**Request:** `{game_dir}/automodpack/autotest/bridge-command.json` -```json -{"token": "...", "op": "get_screen"} -{"token": "...", "op": "click", "widgetId": 42} -{"token": "...", "op": "click", "selector": {"text": "Continue"}} -{"token": "...", "op": "set_text", "selector": {"type": "EditBox", "index": 0}, "text": "AB:CD:..."} -{"token": "...", "op": "verify_fingerprint", "fingerprint": "AB:CD:..."} -{"token": "...", "op": "connect", "host": "amp-s-...", "port": 25565} -{"token": "...", "op": "set_screen"} -{"token": "...", "op": "get_widgets"} -{"token": "...", "op": "quit"} -{"token": "...", "op": "ping"} -``` +The `download-only` scenario stops after the first sync and file verification. +Use it for faster debugging when restart/rejoin behavior is not relevant. -**Response:** `bridge-response.json` -```json -{"ok": true, "screenClass": "FingerprintVerificationScreen", "widgets": [...]} -{"ok": false, "error": "No such widget"} -``` +## Configuration -The command file is written atomically (`.tmp` + `rename`). The client -polls for it, executes `op`, and writes the response. The Python bridge -client (`bridge.py → BridgeClient.request()`) polls for the response -file for up to 30 seconds, sleeping 50ms between polls. +- `settings.yaml` contains default paths, Docker images, server config, + timeouts, and runner defaults. +- `targets.yaml` lists Minecraft/loader/Java combinations. +- `scenarios/*.yaml` defines test flows and scenario-specific server files. -Available operations: +Common run options: -| `op` | Payload | Response | -|------|---------|----------| -| `ping` | — | `{"ok": true}` | -| `get_screen` | — | `{"ok": true, "screenClass": "..."}` | -| `get_widgets` | — | `{"ok": true, "screenClass": "...", "widgets": [{"id": N, "type": "...", "text": "...", "active": bool}]}` | -| `click` | `widgetId` or `selector` (`{text: "..."}`) | `{"ok": true}` | -| `set_text` | `selector` + `text` | `{"ok": true}` | -| `set_screen` | — | Clear the current screen (used to abort a stuck connection) | -| `connect` | `host`, `port` | `{"ok": true}` | -| `verify_fingerprint` | `fingerprint` | `{"ok": true}` — types fingerprint + clicks Verify | -| `quit` | — | `{"ok": true}` — calls `Minecraft.getInstance().stop()` off the main thread | +| Option | Description | +| --- | --- | +| `--target ID` | Target from `targets.yaml`, or `all`. | +| `--scenario ID` | Scenario file stem from `scenarios/`. | +| `--jobs N` | Number of targets to run in parallel. | +| `--artifact-dir PATH` | Directory containing merged AutoModpack jars. | +| `--out-dir PATH` | Directory for logs and `results.json`. | +| `--client-image IMG` | Docker image used for HeadlessMC clients. | -## Scenario YAML reference +## Scenarios + +A scenario is an ordered list of registered phases plus optional topology and +server-file configuration: ```yaml -# Required -id: my-test # Scenario identifier -flow: # Phase sequence (ordered) +id: download-only + +flow: - launch_server - - wait_server - launch_client + - read_fingerprint + - wait_server - wait_bridge - - ... - -# Optional -description: | # Human-readable (not used by code) - What this scenario does + - ensure_ready + - connect + - wait_fingerprint + - accept_fingerprint + - wait_danger + - click_confirm + - wait_download + - verify_files + - quit topology: server: - type: FABRIC # Override engine type (default: settings.yaml → serverTypes) - image: itzg/minecraft-server # Override server image - memory: 4G # Container memory (default: 2G) - env: # Extra env vars (merged with settings.yaml → server.env) + memory: 2G + env: ENABLE_ROLLING_LOGS: "false" - modrinth: - projects: # Modrinth project slugs (ferrite-core? = optional dependency) - - ferrite-core? - projectsByLoader: # Per-loader project list - fabric: [sodium] - version: "1.21" # Modrinth version filter - versionType: release # Modrinth version type - dependencies: true # Auto-include dependencies - serverCache: - enabled: true # Default: true - clean: false # Purge volume before each run serverFiles: - modpackName: amp-autotest # Modpack namespace - marker: config/amp-autotest-marker.json # Path that signals "sync complete" - expectedMods: # Glob patterns for verify_mods phase - - "ferritecore*.jar" - files: # Files written to host-modpack/main/ (synced to client) - - path: config/test-file.txt + modpackName: amp-autotest + marker: config/amp-autotest-marker.json + files: + - path: config/example.txt content: "hello\n" - -timeouts: # Override settings.yaml timeouts per-scenario - serverStartSeconds: 300 - clientStartSeconds: 300 - downloadFileSeconds: 300 - rejoinSeconds: 180 -``` - -### scenarios/ available - -| File | ID | Flow summary | -|------|----|-------------| -| `sync.yaml` | `sync` | Full end-to-end: server+boot → fingerprint → download → verify → restart → rejoin → in-game check | -| `download-only.yaml` | `download-only` | Same as sync but skips restart and rejoin (faster debug iteration) | - -## settings.yaml reference - -```yaml -paths: - artifactDir: merged # Artifact directory (relative to repo root) - outDir: autotester/out # Test output directory - -images: - server: itzg/minecraft-server # Server Docker image - client: automodpack-autotest-client:local # Client Docker image - serverTagTemplate: "java{java}" # Tag template (e.g. itzg/minecraft-server:java21) - -run: - target: all # Default --target - scenario: sync # Default --scenario - jobs: 1 # Default --jobs - retryMax: 0 # Not implemented (reserved) - -server: - memory: 2G # Default container RAM - env: # Default env vars for itzg/minecraft-server - EULA: "TRUE" - ONLINE_MODE: "FALSE" - DIFFICULTY: "peaceful" - -serverTypes: # Maps loader → TYPE env var - fabric: FABRIC - forge: FORGE - neoforge: NEOFORGE - -headlessmc: - version: "2.9.0" # HeadlessMC launcher version in client image - -timeouts: - serverStartSeconds: 180 # Max wait for server "Done (" - clientStartSeconds: 180 # Max wait for bridge to appear - downloadFileSeconds: 180 # Max wait for download marker - rejoinSeconds: 90 # Max wait for in-game rejoin - -serverCache: # Docker volume for server JARs - enabled: true - volumePrefix: "amp-server-cache" # Volume name: {prefix}-{target.id} - clean: false - -automodpack: - config: # Written to server's automodpack-server.json - DO_NOT_CHANGE_IT: 2 - modpackHost: true - generateModpackOnStart: true - syncedFiles: - - "/mods/*.jar" - - "/kubejs/**" - - "!/kubejs/server_scripts/**" - ... -``` - -## targets.yaml reference - -```yaml -defaults: - fabricLoader: "0.17.3" # Default Fabric loader version - java: 21 # Default Java version - -targets: - - id: "1.21.11-fabric" # Unique target ID (used with --target) - minecraft: "1.21.11" # Minecraft version - loader: "fabric" # fabric | forge | neoforge - java: 21 # Java version (17 | 21 | 25) - fabricLoader: "0.17.3" # Fabric loader version (required for fabric) - forgeVersion: "47.3.0" # Forge version (required for forge) - neoforgeVersion: "21.11.37-beta" # NeoForge version (required for neoforge) ``` -Artifact discovery: the runner globs `{artifactDir}/automodpack-mc{minecraft}-{loader}-*.jar` -and uses the newest match. - -## Docker networking - -Each test case creates an isolated Docker bridge network. The server is -reachable from the client by its container name. The network and both -containers are destroyed in the `finally` block of `run_case()`. - -### Client container (HeadlessMC) - -Built via `build-images`. Uses `run-headlessmc-client` entrypoint which: -1. Selects the correct Java binary (`java-{version}-openjdk-amd64`) -2. Downloads HeadlessMC launcher if missing -3. Launches Minecraft with AutoModpack - -Environment: - -| Variable | Description | -|----------|-------------| -| `AM_AUTOTEST_BRIDGE_TOKEN` | Auth token; must match bridge request `token` | -| `AM_AUTOTEST_GAME_DIR` | Path to client game directory (mounted from host) | -| `AM_AUTOTEST_HMC_DIR` | HeadlessMC cache directory (mounted from host) | -| `AUTOTEST_DOCKER_UID` | Container UID (for file ownership) | -| `AUTOTEST_DOCKER_GID` | Container GID (for file ownership) | +Useful phases: -### Server container (itzg/minecraft-server) +| Phase | Purpose | +| --- | --- | +| `launch_server` | Start the Minecraft server container. | +| `wait_server` | Wait until the server logs `Done (`. | +| `launch_client` | Start a HeadlessMC client container. | +| `wait_bridge` | Wait until the in-game bridge is ready. | +| `ensure_ready` | Wait for title screen and dismiss known prompts. | +| `connect` | Connect the client to the test server. | +| `read_fingerprint` | Extract the AutoModpack TLS fingerprint from server logs. | +| `wait_fingerprint` | Wait for the fingerprint validation screen. | +| `accept_fingerprint` | Enter and accept the expected fingerprint. | +| `wait_danger` | Wait for the update confirmation screen. | +| `click_confirm` | Confirm the sync/update. | +| `wait_download` | Wait for the marker file in the synced modpack. | +| `verify_files` | Verify all configured `serverFiles.files` exist on the client. | +| `click_restart` | Click restart/quit on the restart screen if shown. | +| `wait_join` | Verify the client reaches the in-game state. | +| `quit` | Stop the client through the bridge. | -Uses a Docker volume (`amp-server-cache-{target.id}`) to persist server JARs -between runs. The host `mods/` and `automodpack/` directories are bind-mounted -into `/data/mods` and `/data/automodpack` so the server sees the test's -automodpack.jar and config. +## Output -## Caching +By default, output is written to `autotester/out/`. -- **Server JARs**: Docker volume `amp-server-cache-{target.id}`. Disable by - setting `serverCache.enabled: false` in `settings.yaml`. -- **Client HMC cache**: The `.hmc-cache/` directory is seeded from the - previous run's cache via `cp --reflink=auto` before each test, avoiding - re-download of Minecraft jars. It lives at `{out_dir}/../.hmc-cache/`. +Important files: -## Environment variables +- `results.json`: aggregated pass/fail result. +- `/amp-s-*.log`: server container log. +- `/amp-c-*.log`: client container log. +- `/client/game/automodpack/modpacks/`: synced client modpacks. -| Variable | Default | Used in | -|----------|---------|---------| -| `AUTOTEST_DOCKER_UID` | `os.getuid()` | Client container `-u` flag | -| `AUTOTEST_DOCKER_GID` | `os.getgid()` | Client container `-u` flag | +`results.json` has this shape: -Set these when running as root or inside a CI container where uid/gid -mismatches would cause permission issues on bind-mounted volumes. +```json +{ + "ok": false, + "results": [ + { + "target": "1.21.11-fabric", + "scenario": "sync", + "ok": false, + "duration": 142.7, + "error": "Download marker file ... did not appear" + } + ] +} +``` -## Adding a target +## CI Workflow -1. Add an entry to `targets.yaml` with `id`, `minecraft`, `loader`, - `java`, and the appropriate loader version field. -2. The `build-images` step is already version-agnostic — any Java version - in the matrix will work as long as it's between 17 and 25 and available - in the container (`java-{version}-openjdk-amd64`). -3. Add the target ID to `.github/workflows/ingame-tests.yml` matrix to - run it in CI. +`.github/workflows/ingame-tests.yml` is manual (`workflow_dispatch`). It builds +the normal project artifacts, builds the autotest client image, runs the chosen +target/scenario matrix, uploads per-target logs, and publishes an aggregate +summary. -## Adding a scenario +## Bridge -Create `scenarios/my-test.yaml`: +The bridge is disabled unless the JVM property `automodpack.autotest=true` is +present. Test containers pass a per-run token and game directory through JVM +properties. Commands and responses are JSON files under: -```yaml -id: my-test -flow: [launch_server, wait_server, launch_client, wait_bridge, quit] -topology: - server: - modrinth: - projects: [ferrite-core] - dependencies: true +```text +/automodpack/autotest/ ``` -Then run: `uv --project autotester run autotester run --scenario my-test` - -## CI (GitHub Actions) - -`.github/workflows/ingame-tests.yml` runs each target as a separate job -in a matrix. Each job: checks out repo → Build AutoModpack → Build client -image → Run autotest → Upload artifacts. Artifacts include the full -`autotester/out/` directory (logs + results.json). The workflow can be -triggered manually via `workflow_dispatch` with overridable `scenario`, -`target`, and `jobs` inputs. +The bridge is intentionally small and only exposes the UI actions needed by the +runner: inspect screen/widgets, click buttons, set text, connect, verify +fingerprints, and quit. diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml index 5be57a5e2..9a9fad007 100644 --- a/autotester/scenarios/download-only.yaml +++ b/autotester/scenarios/download-only.yaml @@ -1,15 +1,6 @@ -# ── scenario: download-only ───────────────────────────────────────────── -# Minimal: launch_server → wait_server → read_fingerprint → launch_client -# → connect → fingerprint → download → verify → quit. -# Skips restart and rejoin — faster feedback when debugging downloads. -# -# See sync.yaml for detailed documentation on serverFiles, server engine, -# server caching (Docker volumes), and client caching (HMC cache seed). - id: download-only description: | - Launch server/client → accept fingerprint → sync modpack → verify → quit. - Skips restart/rejoin for faster iteration. + Launch server/client, accept fingerprint, sync modpack, verify files, and quit. flow: - launch_server diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index 1f0fa1cbe..6787f8f68 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -1,103 +1,30 @@ -# ── scenario: sync ────────────────────────────────────────────────────── -# Full end-to-end: launches server + client, connects, verifies fingerprint, -# downloads the modpack, verifies files, restarts, and rejoins. -# -# CLI: autotester run --scenario sync -# New scenario: copy this file, change `id` and `flow:` list. -# -# ── Phases ────────────────────────────────────────────────────────────── -# Edit the `flow:` list below. Each entry is a phase name registered in -# runner.py → PHASES dict. Add/remove/reorder freely — no Python changes. -# -# Known phases: -# launch_server Start the server container (itzg/minecraft-server). -# wait_server Wait for "Done (" in server logs. -# read_fingerprint Extract TLS certificate fingerprint from server logs. -# launch_client Start/restart the client container (HeadlessMC). -# wait_bridge Poll bridge-state.json, ping the bridge TCP handler. -# ensure_ready Wait and/or click through "Continue" till title screen appears. -# connect Bridge: connect to server at {srv_name}:25565. -# wait_fingerprint Wait until the client opens FingerprintVerificationScreen. -# accept_fingerprint Type fingerprint into EditBox, click Verify. -# skip_fingerprint Skip verification (versions without EditBox). -# wait_danger Poll until DangerScreen appears. -# click_confirm Click the confirm button on DangerScreen. -# wait_download Poll for the marker file in the synced modpack. -# verify_files Check all serverFiles exist in the synced modpack. -# verify_mods Check expected mod glob patterns match installed jars. -# click_restart If RestartScreen → click Restart; wait for exit. -# quit Bridge: quit game → wait for client exit. -# wait_join Poll null screenClass (player in-game). -# -# ── serverFiles ───────────────────────────────────────────────────────── -# Describes files the SERVER hosts in its modpack; the client downloads -# and syncs them. Everything is under the server's host-modpack/main/ dir. -# -# modpackName Namespace for the modpack (both server and client). -# Server: host-modpack/main/ -# Client: modpacks/{modpackName}/ -# -# marker Relative path inside the modpack that signals -# "sync completed". Written to host-modpack/main/ before -# server start. Verified by `wait_download` on the client. -# The file is NOT checked into the repo — it is generated -# by _prepare_server() before each test run. -# -# files List of {path, content} — files written to the server's -# host-modpack/main/ BEFORE server launch. After sync, -# `verify_files` checks each exists under the client's -# modpacks/{modpackName}/ directory. -# -# expectedMods Glob patterns for .jar files expected in the synced -# mods/ directory. Add `verify_mods` to flow to enable. -# Not used by default (add when testing mod filtering). -# -# ── Topology ──────────────────────────────────────────────────────────── -# topology.server.type overrides the engine mapping from settings.yaml: -# fabric → FABRIC, forge → FORGE, neoforge → NEOFORGE -# If unset, settings.yaml → serverTypes maps loader→type. -# The value becomes the TYPE env var for itzg/minecraft-server. -# -# topology.server.modrinth defines extra mods via Modrinth API. -# topology.server.env merges with (and overrides) settings.yaml → server.env. -# topology.server.memory sets container memory (default 2G). -# -# ── Caching ───────────────────────────────────────────────────────────── -# Server: Docker volume amp-server-cache-{target.id} persists JARs. -# Set serverCache.clean: true in settings.yaml to purge before each run. -# Client: HMC cache at ~/.hmc-cache/ seeded via cp --reflink before each -# run to avoid re-downloading Minecraft jars every test. -# -# ── Timeouts ──────────────────────────────────────────────────────────── -# Per-scenario overrides for settings.yaml timeouts. Set under timeouts: {}. -# Keys: serverStartSeconds, clientStartSeconds, downloadFileSeconds, rejoinSeconds. - id: sync description: | - Launch server/client → accept fingerprint → sync modpack → verify → restart → rejoin → in-game check. + Launch server/client, accept fingerprint, sync modpack, verify files, restart, + rejoin, and verify the player reaches the game. flow: - - launch_server # start the server container - - launch_client # start client immediately — both boot in parallel - - read_fingerprint # extract TLS fingerprint from server logs - - wait_server # wait for "Done (" — runs concurrent with client boot - - wait_bridge # poll bridge-state.json, ping bridge - - ensure_ready # wait till title scrren and click through continue on welcome screen - - connect # bridge.connect(host, port) - - wait_fingerprint # wait for FingerprintVerificationScreen - - accept_fingerprint # type fingerprint in EditBox → click Verify - - wait_danger # poll until DangerScreen appears - - click_confirm # click last active Button (Confirm) - - wait_download # poll for marker file in synced modpack - - verify_files # check all serverFiles exist - - click_restart # if RestartScreen → click Restart; wait for exit - - quit # bridge.quit() → wait for client exit - - launch_client # fresh client container (rejoin) - - wait_bridge # wait for bridge on new client - - ensure_ready # wait till title scrren appears - - connect # connect to server - - wait_join # poll null screenClass (in-game) - - quit # quit + - launch_server + - launch_client + - read_fingerprint + - wait_server + - wait_bridge + - ensure_ready + - connect + - wait_fingerprint + - accept_fingerprint + - wait_danger + - click_confirm + - wait_download + - verify_files + - click_restart + - quit + - launch_client + - wait_bridge + - ensure_ready + - connect + - wait_join + - quit topology: server: diff --git a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java index 3b14b48f5..6d66f75c6 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java +++ b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java @@ -73,6 +73,10 @@ public static CompletableFuture createAsync( int poolSize, Function> trustCallback) { + if (poolSize < 1) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Pool size must be greater than 0")); + } + return CompletableFuture.supplyAsync(() -> { KeyStore keyStore = loadDefaultKeyStore(); AtomicReference capturedChain = new AtomicReference<>(); @@ -230,10 +234,17 @@ private static SSLContext createSSLContext(KeyStore trustedCertificates, Consume public static DownloadClient tryCreate(Jsons.ModpackAddresses modpackAddresses, byte[] secretBytes, int poolSize, Function trustedByUserCallback) { try { - return new DownloadClient(modpackAddresses, secretBytes, poolSize, trustedByUserCallback); - } catch (IOException e) { - LOGGER.error("Failed to create download client: {}", e.getMessage()); - LOGGER.debug(e); + Function> asyncCallback = trustedByUserCallback == null + ? null + : certificate -> CompletableFuture.completedFuture(trustedByUserCallback.apply(certificate)); + return createAsync(modpackAddresses, secretBytes, poolSize, asyncCallback).get(); + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + Throwable cause = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e; + LOGGER.error("Failed to create download client: {}", cause.getMessage()); + LOGGER.debug(cause); return null; } } diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index 4a6625172..c6aac47a1 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -24,7 +24,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; @@ -650,45 +649,39 @@ public static Path getModpackPath(InetSocketAddress address, String modpackName) public static Optional requestServerModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, boolean allowAskingUser) { return fetchModpackContent(modpackAddresses, secret, (client) -> client.downloadFile(new byte[0], modpackContentTempFile, null), - "Fetched", allowAskingUser); + allowAskingUser); } public static Optional refreshServerModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, byte[][] fileHashes, boolean allowAskingUser) { return fetchModpackContent(modpackAddresses, secret, (client) -> client.requestRefresh(fileHashes, modpackContentTempFile), - "Re-fetched", allowAskingUser); + allowAskingUser); } - private static Optional fetchModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, Function> operation, String fetchType, boolean allowAskingUser) { + private static Optional fetchModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, Function> operation, boolean allowAskingUser) { if (secret == null) return Optional.empty(); if (modpackAddresses.isAnyEmpty()) throw new IllegalArgumentException("Modpack addresses are empty!"); - try (DownloadClient client = DownloadClient.tryCreate(modpackAddresses, secret.secretBytes(), 1, userValidationCallback(modpackAddresses.hostAddress, allowAskingUser))) { - if (client == null) return Optional.empty(); - var future = operation.apply(client); - Path path = future.get(); - var content = Optional.ofNullable(ConfigTools.loadModpackContent(path)); - Files.deleteIfExists(modpackContentTempFile); - - if (content.isPresent() && potentiallyMalicious(content.get())) { - return Optional.empty(); - } - - return content; + try { + return fetchModpackContentAsync( + modpackAddresses, + secret, + operation, + blockingValidationCallback(modpackAddresses.hostAddress, allowAskingUser) + ).get(); } catch (Exception e) { LOGGER.error("Error while getting server modpack content", e); + return Optional.empty(); } - - return Optional.empty(); } public static boolean canConnectModpackHost(Jsons.ModpackAddresses modpackAddresses) { if (modpackAddresses.isAnyEmpty()) throw new IllegalArgumentException("Modpack addresses are empty!"); - try (DownloadClient client = DownloadClient.tryCreate(modpackAddresses, null, 1, null)) { + try (DownloadClient client = DownloadClient.createAsync(modpackAddresses, null, 1, null).get()) { return client != null; } catch (Exception e) { LOGGER.error("Error while pinging AutoModpack host server", e); @@ -773,40 +766,72 @@ private static Boolean askUserAboutCertificate(InetSocketAddress address, String // ---- Async versions (non-blocking, used by login packet flow) ---- public static CompletableFuture> requestServerModpackContentAsync(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, boolean allowAskingUser) { + if (secret == null) + return CompletableFuture.completedFuture(Optional.empty()); + if (modpackAddresses.isAnyEmpty()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Modpack addresses are empty!")); + } + return fetchModpackContentAsync(modpackAddresses, secret, (client) -> client.downloadFile(new byte[0], modpackContentTempFile, null), - "Fetched", allowAskingUser); + userValidationCallbackAsync(modpackAddresses.hostAddress, allowAskingUser)); } - private static CompletableFuture> fetchModpackContentAsync(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, Function> operation, String fetchType, boolean allowAskingUser) { + private static CompletableFuture> fetchModpackContentAsync( + Jsons.ModpackAddresses modpackAddresses, + Secrets.Secret secret, + Function> operation, + Function> trustCallback) { if (secret == null) return CompletableFuture.completedFuture(Optional.empty()); - if (modpackAddresses.isAnyEmpty()) - throw new IllegalArgumentException("Modpack addresses are empty!"); + if (modpackAddresses.isAnyEmpty()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Modpack addresses are empty!")); + } - return DownloadClient.createAsync(modpackAddresses, secret.secretBytes(), 1, userValidationCallbackAsync(modpackAddresses.hostAddress, allowAskingUser)) - .thenApply(client -> { - try (client) { - Path path = operation.apply(client).get(); - var content = Optional.ofNullable(ConfigTools.loadModpackContent(path)); - Files.deleteIfExists(modpackContentTempFile); + return DownloadClient.createAsync(modpackAddresses, secret.secretBytes(), 1, trustCallback) + .thenCompose(client -> { + CompletableFuture operationFuture; + try { + operationFuture = operation.apply(client); + } catch (Exception e) { + try { + client.close(); + } catch (Exception ignored) {} + return CompletableFuture.completedFuture(Optional.empty()); + } + + return operationFuture.handle((path, throwable) -> { + try (client) { + if (throwable != null) { + LOGGER.error("Error while getting server modpack content", throwable); + return Optional.empty(); + } + + var content = Optional.ofNullable(ConfigTools.loadModpackContent(path)); + Files.deleteIfExists(modpackContentTempFile); + + if (content.isPresent() && potentiallyMalicious(content.get())) { + return Optional.empty(); + } - if (content.isPresent() && potentiallyMalicious(content.get())) { + return content; + } catch (Exception e) { + LOGGER.error("Error while getting server modpack content", e); return Optional.empty(); } - - return content; - } catch (Exception e) { - LOGGER.error("Error while getting server modpack content", e); - return Optional.empty(); - } + }); }) .exceptionally(e -> { LOGGER.error("Error while getting server modpack content", e); - return Optional.empty(); + return Optional.empty(); }); } + private static Function> blockingValidationCallback(InetSocketAddress address, boolean allowAskingUser) { + Function callback = userValidationCallback(address, allowAskingUser); + return certificate -> CompletableFuture.completedFuture(callback.apply(certificate)); + } + public static Function> userValidationCallbackAsync(InetSocketAddress address, boolean allowAskingUser) { return certificate -> { String fingerprint; diff --git a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java index e12cdad5e..e859c9ab7 100644 --- a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java +++ b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java @@ -1,5 +1,6 @@ package pl.skidam.automodpack.networking.client; +import io.netty.buffer.Unpooled; import org.jetbrains.annotations.Nullable; import pl.skidam.automodpack.mixin.core.ClientLoginNetworkHandlerAccessor; import pl.skidam.automodpack.networking.LoginQueryParser; @@ -47,15 +48,26 @@ private boolean handlePacket(int queryId, Identifier channelName, FriendlyByteBu try { CompletableFuture future = handler.receive(this.client, this.handler, buf); - future.thenAccept(resultBuf -> { - ServerboundCustomQueryAnswerPacket packet = new ServerboundCustomQueryAnswerPacket(queryId, /*? if <1.20.2 {*/ /*resultBuf *//*?} else {*/ new LoginResponsePayload(channelName, resultBuf) /*?}*/); - ((ClientLoginNetworkHandlerAccessor) this.handler).getConnection().send(packet); + future.whenComplete((resultBuf, throwable) -> { + if (throwable != null) { + LOGGER.error("Failed to handle login query in channel \"{}\"", channelName, throwable); + resultBuf = new FriendlyByteBuf(Unpooled.buffer()); + } + if (resultBuf == null) { + resultBuf = new FriendlyByteBuf(Unpooled.buffer()); + } + sendResponse(queryId, channelName, resultBuf); }); } catch (Throwable e) { LOGGER.error("Encountered exception while handling in channel with name \"{}\"", channelName, e); - throw e; + sendResponse(queryId, channelName, new FriendlyByteBuf(Unpooled.buffer())); } return true; } + + private void sendResponse(int queryId, Identifier channelName, FriendlyByteBuf resultBuf) { + ServerboundCustomQueryAnswerPacket packet = new ServerboundCustomQueryAnswerPacket(queryId, /*? if <1.20.2 {*/ /*resultBuf *//*?} else {*/ new LoginResponsePayload(channelName, resultBuf) /*?}*/); + ((ClientLoginNetworkHandlerAccessor) this.handler).getConnection().send(packet); + } } diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index e524629dc..5003da9a3 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -39,9 +39,9 @@ public static CompletableFuture receive(Minecraft client, Clien return CompletableFuture.completedFuture(error); } - String packetAddress = dataPacket.address; + String packetAddress = dataPacket.address == null ? "" : dataPacket.address; int packetPort = dataPacket.port; - String modpackName = dataPacket.modpackName; + String modpackName = dataPacket.modpackName == null ? "" : dataPacket.modpackName; Secrets.Secret secret = dataPacket.secret; boolean modRequired = dataPacket.modRequired; boolean requiresMagic = dataPacket.requiresMagic; @@ -56,32 +56,29 @@ public static CompletableFuture receive(Minecraft client, Clien ModPackets.setOriginalServerAddress(null); // Reset for next server reconnection if (serverAddress == null) { LOGGER.error("Server address is null! Something gone very wrong! Please report this issue! https://github.com/Skidamek/AutoModpack/issues"); - return CompletableFuture.completedFuture(new FriendlyByteBuf(Unpooled.buffer())); + return CompletableFuture.completedFuture(buildResponse(null)); } - // Get actual address of the server client have connected to and format it - InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); - String effectiveHost; - int effectivePort; - - if (packetAddress.isBlank()) { - effectiveHost = connectedAddress.getAddress().getHostAddress(); - } else { - effectiveHost = packetAddress; - } - - if (packetPort == -1) { - effectivePort = connectedAddress.getPort(); - } else { - effectivePort = packetPort; - } + Path modpackDir; + Jsons.ModpackAddresses modpackAddresses; + try { + InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); + var connectedInetAddress = connectedAddress.getAddress(); + String effectiveHost = packetAddress.isBlank() + ? (connectedInetAddress == null ? connectedAddress.getHostString() : connectedInetAddress.getHostAddress()) + : packetAddress; + int effectivePort = packetPort == -1 ? connectedAddress.getPort() : packetPort; - InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); + InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); - LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); + LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); - Path modpackDir = ModpackUtils.getModpackPath(modpackAddress, modpackName); - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(modpackAddress, serverAddress, requiresMagic); + modpackDir = ModpackUtils.getModpackPath(modpackAddress, modpackName); + modpackAddresses = new Jsons.ModpackAddresses(modpackAddress, serverAddress, requiresMagic); + } catch (Exception e) { + LOGGER.error("Error preparing modpack address from data packet", e); + return CompletableFuture.completedFuture(buildResponse(null)); + } return ModpackUtils.requestServerModpackContentAsync(modpackAddresses, secret, true) .thenApply(optionalServerModpackContent -> { From 1c60980267d02ee609b4bfff22ebd0191902a8b7 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 30 May 2026 19:41:07 +0200 Subject: [PATCH 28/44] fix forge 1.19.2 classloading crash in async data packet handler thenApply inherits the completing thread (pool-5-thread-* from DownloadClient's executor), where Forge's ModuleClassLoader.findClass throws CNFE for classes loaded for the first time. switch to thenApplyAsync so the lambda lands on ForkJoinPool.commonPool(), whose workers can resolve classes normally. --- .../skidam/automodpack/networking/packet/DataC2SPacket.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 5003da9a3..188cbc532 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -81,7 +81,7 @@ public static CompletableFuture receive(Minecraft client, Clien } return ModpackUtils.requestServerModpackContentAsync(modpackAddresses, secret, true) - .thenApply(optionalServerModpackContent -> { + .thenApplyAsync(optionalServerModpackContent -> { long t0 = System.currentTimeMillis(); if (optionalServerModpackContent.isEmpty()) { @@ -148,6 +148,7 @@ private static FriendlyByteBuf buildResponse(Boolean needsDisconnecting) { } private static void disconnectImmediately(ClientHandshakePacketListenerImpl clientLoginNetworkHandler) { - ((ClientConnectionAccessor) ((ClientLoginNetworkHandlerAccessor) clientLoginNetworkHandler).getConnection()).getChannel().disconnect(); + var channel = ((ClientConnectionAccessor) ((ClientLoginNetworkHandlerAccessor) clientLoginNetworkHandler).getConnection()).getChannel(); + channel.disconnect(); } } From b77f36b4eb4ac928476c16b924c1f0efc132ab5a Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 30 May 2026 19:58:20 +0200 Subject: [PATCH 29/44] fix same classloader thread issue in fetchModpackContentAsync handle the .handle callback inherited the Connection executor thread (pool-5-thread-*) where ModuleClassLoader.findClass can fail for unresolved classes. switch to handleAsync so it runs on ForkJoinPool.commonPool() like the DataC2SPacket fix. --- .../pl/skidam/automodpack_loader_core/client/ModpackUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index c6aac47a1..ec08e60fb 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -800,7 +800,7 @@ private static CompletableFuture> fetchModp return CompletableFuture.completedFuture(Optional.empty()); } - return operationFuture.handle((path, throwable) -> { + return operationFuture.handleAsync((path, throwable) -> { try (client) { if (throwable != null) { LOGGER.error("Error while getting server modpack content", throwable); From e5ec725da061e174fa46e2f42b08ddb6ce964c81 Mon Sep 17 00:00:00 2001 From: skidam Date: Sun, 31 May 2026 21:45:28 +0200 Subject: [PATCH 30/44] improvements --- .../skidam/automodpack/client/ScreenImpl.java | 18 +---------- .../client/ClientLoginNetworkAddon.java | 1 + .../networking/packet/DataC2SPacket.java | 32 ++++++++++++++----- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java index ed85e2bc2..1c5beb989 100644 --- a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java +++ b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java @@ -10,30 +10,14 @@ import java.nio.file.Path; import java.util.Optional; -import java.util.concurrent.TimeUnit; import net.minecraft.client.Minecraft; -import pl.skidam.automodpack_core.Constants; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; public class ScreenImpl implements ScreenService { private static void executeOnClient(Runnable task) { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); - Minecraft client; - while ((client = Minecraft.getInstance()) == null) { - if (System.nanoTime() > deadline) { - Constants.LOGGER.warn("Could not execute on client: Minecraft not yet initialized"); - return; - } - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - client.execute(task); + Minecraft.getInstance().execute(task); } @Override diff --git a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java index e859c9ab7..486255577 100644 --- a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java +++ b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java @@ -61,6 +61,7 @@ private boolean handlePacket(int queryId, Identifier channelName, FriendlyByteBu } catch (Throwable e) { LOGGER.error("Encountered exception while handling in channel with name \"{}\"", channelName, e); sendResponse(queryId, channelName, new FriendlyByteBuf(Unpooled.buffer())); + throw e; } return true; diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 188cbc532..9d5590f28 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -62,14 +62,30 @@ public static CompletableFuture receive(Minecraft client, Clien Path modpackDir; Jsons.ModpackAddresses modpackAddresses; try { - InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); - var connectedInetAddress = connectedAddress.getAddress(); - String effectiveHost = packetAddress.isBlank() - ? (connectedInetAddress == null ? connectedAddress.getHostString() : connectedInetAddress.getHostAddress()) - : packetAddress; - int effectivePort = packetPort == -1 ? connectedAddress.getPort() : packetPort; - - InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); + // Get actual address of the server client have connected to and format it + InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); + String effectiveHost; + int effectivePort; + + // If the packet specifies a non-blank address, use it or else use address from the server client have connected to. + // Important! Use getAddress().getHostAddress() instead of getHostString() + // because Minecraft creates connectedAddress instance through a constructor which attempts a reverse DNS lookup + // which resolves PTR record for the IP address and stores the resolved hostname in the hostname field. + if (packetAddress.isBlank()) { + var connectedInetAddress = connectedAddress.getAddress(); + effectiveHost = connectedInetAddress == null ? connectedAddress.getHostString() : connectedInetAddress.getHostAddress(); + } else { + effectiveHost = packetAddress; + } + + if (packetPort == -1) { + effectivePort = connectedAddress.getPort(); + } else { + effectivePort = packetPort; + } + + // Construct the final modpack address + InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); From 595ce5f7d6bbe89d14c65bb623ccaec2d328363f Mon Sep 17 00:00:00 2001 From: skidam Date: Sun, 31 May 2026 22:14:44 +0200 Subject: [PATCH 31/44] remove debug deadlines --- .../client/ModpackUtils.java | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index ec08e60fb..19baba1b4 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -725,21 +725,10 @@ public static Function userValidationCallback(InetSock private static Boolean askUserAboutCertificate(InetSocketAddress address, String fingerprint) { LOGGER.info("Asking user for {}", address.getHostString()); - Object parent = null; - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); - while (parent == null) { - parent = new ScreenManager().getScreen().orElse(null); - if (parent == null) { - if (System.nanoTime() > deadline) { - LOGGER.warn("No screen available, cannot ask user"); - return false; - } - try { - Thread.sleep(100); - } catch (InterruptedException e) { - return false; - } - } + var parent = new ScreenManager().getScreen().orElse(null); + if (parent == null) { + LOGGER.warn("No screen available, cannot ask user"); + return false; } CountDownLatch latch = new CountDownLatch(1); @@ -855,22 +844,11 @@ private static CompletableFuture askUserAboutCertificateAsync(InetSocke LOGGER.info("Asking user for {}", address.getHostString()); return CompletableFuture.supplyAsync(() -> { - Object screen = null; - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); - while (screen == null) { - screen = new ScreenManager().getScreen().orElse(null); - if (screen == null) { - if (System.nanoTime() > deadline) { - LOGGER.warn("No screen available, cannot ask user"); - return false; - } - try { - Thread.sleep(100); - } catch (InterruptedException e) { - return false; - } - } - } + var parent = new ScreenManager().getScreen().orElse(null); + if (parent == null) { + LOGGER.warn("No screen available, cannot ask user"); + return false; + } CompletableFuture future = new CompletableFuture<>(); Runnable trustAction = () -> { @@ -879,7 +857,7 @@ private static CompletableFuture askUserAboutCertificateAsync(InetSocke future.complete(true); }; Runnable cancelAction = () -> future.complete(false); - new ScreenManager().validation(screen, fingerprint, trustAction, cancelAction); + new ScreenManager().validation(parent, fingerprint, trustAction, cancelAction); try { return future.get(120, TimeUnit.SECONDS); From 142963aa5846879cca92e4f33a5814183a631519 Mon Sep 17 00:00:00 2001 From: skidam Date: Sun, 31 May 2026 22:15:22 +0200 Subject: [PATCH 32/44] stuf --- .../pl/skidam/automodpack_loader_core/client/ModpackUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index 19baba1b4..a2a145353 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -812,7 +812,7 @@ private static CompletableFuture> fetchModp }) .exceptionally(e -> { LOGGER.error("Error while getting server modpack content", e); - return Optional.empty(); + return Optional.empty(); }); } From ebb60d7cab35d077d29e67a3c66b008277618e7a Mon Sep 17 00:00:00 2001 From: skidam Date: Mon, 1 Jun 2026 15:32:07 +0200 Subject: [PATCH 33/44] remove debug logs --- .../pl/skidam/automodpack/networking/packet/DataC2SPacket.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 9d5590f28..6497f06ad 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -112,11 +112,8 @@ public static CompletableFuture receive(Minecraft client, Clien ModpackUtils.UpdateCheckResult updateCheckResult = ModpackUtils.isUpdate(optionalServerModpackContent.get(), modpackDir); if (updateCheckResult.requiresUpdate()) { - LOGGER.info("DataC2SPacket: update required, disconnecting immediately (t={})", System.currentTimeMillis() - t0); disconnectImmediately(handler); - LOGGER.info("DataC2SPacket: disconnected, calling processModpackUpdate (t={})", System.currentTimeMillis() - t0); new ModpackUpdater(optionalServerModpackContent.get(), modpackAddresses, secret, modpackDir).processModpackUpdate(updateCheckResult); - LOGGER.info("DataC2SPacket: processModpackUpdate returned (t={})", System.currentTimeMillis() - t0); needsDisconnecting = true; } else { boolean selectedModpackChanged = ModpackUtils.selectModpack(modpackDir, modpackAddresses, Set.of()); From 5e14995ade55d9299208970393a0ad05e883685a Mon Sep 17 00:00:00 2001 From: skidam Date: Tue, 2 Jun 2026 01:30:42 +0200 Subject: [PATCH 34/44] simplify stuff a little --- autotester/README.md | 16 +- autotester/automodpack_autotester/bridge.py | 21 + autotester/automodpack_autotester/runner.py | 211 +++---- .../docker/client/run-headlessmc-client | 18 +- autotester/scenarios/download-only.yaml | 1 - autotester/scenarios/sync.yaml | 2 - autotester/settings.yaml | 3 +- .../client/autotest/AutoTestBridge.java | 558 ++++++++++++------ .../client/autotest/FormattedText.java | 16 + .../autotest/RenderedTextCollector.java | 52 ++ .../skidam/automodpack/init/FabricInit.java | 1 - .../pl/skidam/automodpack/init/ForgeInit.java | 1 - .../skidam/automodpack/init/NeoForgeInit.java | 1 - .../skidam/automodpack/mixin/MixinPlugin.java | 5 + .../mixin/dev/FontRenderMixin.java | 107 ++++ .../automodpack/mixin/dev/MinecraftMixin.java | 41 ++ .../automodpack/mixin/dev/TestButton.java | 50 -- .../resources/automodpack-main.mixins.json | 4 +- stonecutter.properties.toml | 4 +- 19 files changed, 777 insertions(+), 335 deletions(-) create mode 100644 src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java create mode 100644 src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java create mode 100644 src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java create mode 100644 src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java delete mode 100644 src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java diff --git a/autotester/README.md b/autotester/README.md index b299029ed..7fb649b77 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -90,7 +90,6 @@ flow: - read_fingerprint - wait_server - wait_bridge - - ensure_ready - connect - wait_fingerprint - accept_fingerprint @@ -121,13 +120,12 @@ Useful phases: | `launch_server` | Start the Minecraft server container. | | `wait_server` | Wait until the server logs `Done (`. | | `launch_client` | Start a HeadlessMC client container. | -| `wait_bridge` | Wait until the in-game bridge is ready. | -| `ensure_ready` | Wait for title screen and dismiss known prompts. | +| `wait_bridge` | Wait for bridge + client-ready signal from MinecraftMixin. | | `connect` | Connect the client to the test server. | | `read_fingerprint` | Extract the AutoModpack TLS fingerprint from server logs. | -| `wait_fingerprint` | Wait for the fingerprint validation screen. | -| `accept_fingerprint` | Enter and accept the expected fingerprint. | -| `wait_danger` | Wait for the update confirmation screen. | +| `wait_fingerprint` | Wait for a certificate prompt with a text field and Verify button. | +| `accept_fingerprint` | Enter the expected fingerprint and click Verify. | +| `wait_danger` | Wait for the download confirmation prompt. | | `click_confirm` | Confirm the sync/update. | | `wait_download` | Wait for the marker file in the synced modpack. | | `verify_files` | Verify all configured `serverFiles.files` exist on the client. | @@ -180,6 +178,6 @@ properties. Commands and responses are JSON files under: /automodpack/autotest/ ``` -The bridge is intentionally small and only exposes the UI actions needed by the -runner: inspect screen/widgets, click buttons, set text, connect, verify -fingerprints, and quit. +The bridge is intentionally small and generic. It exposes HMC-specifics-style +operations for `gui`, `click`, `text`, `menu`, `close`, `connect`, `disconnect`, +`render`, and `quit`; the runner builds scenario behavior from those primitives. diff --git a/autotester/automodpack_autotester/bridge.py b/autotester/automodpack_autotester/bridge.py index 432e44b29..66def5256 100644 --- a/autotester/automodpack_autotester/bridge.py +++ b/autotester/automodpack_autotester/bridge.py @@ -33,3 +33,24 @@ def request(self, op: str, timeout: float = 30, **payload) -> dict: return data time.sleep(random.uniform(0.03, 0.07)) raise TimeoutError(f"Bridge did not respond to '{op}' after {timeout}s") + + def gui(self, timeout: float = 30) -> dict: + return self.request("gui", timeout=timeout) + + def buttons(self, timeout: float = 30) -> list[dict]: + return list(self.gui(timeout=timeout).get("buttons", [])) + + def text_fields(self, timeout: float = 30) -> list[dict]: + return list(self.gui(timeout=timeout).get("textFields", [])) + + def click(self, element_id: int, timeout: float = 30, **payload) -> dict: + return self.request("click", timeout=timeout, id=element_id, **payload) + + def click_point(self, x: int, y: int, button: int = 0, timeout: float = 30) -> dict: + return self.request("click", timeout=timeout, x=x, y=y, button=button) + + def text(self, element_id: int, value: str, timeout: float = 30) -> dict: + return self.request("text", timeout=timeout, id=element_id, text=value) + + def connect(self, host: str, port: int = 25565, timeout: float = 30) -> dict: + return self.request("connect", timeout=timeout, host=host, port=port) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index 017d58490..133d5fa11 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -138,7 +138,12 @@ def _gid(): def _load_ver(t): - return t.fabric_loader or t.forge_version or t.neoforge_version or "" + if t.loader == "fabric": + return t.fabric_loader or "" + if t.loader == "forge": + return t.forge_version or "" + if t.loader == "neoforge": + return t.neoforge_version or "" def _bridge_state(ctx): @@ -155,6 +160,43 @@ def _await(pred, timeout, msg): raise TimeoutError(msg) +def _button_with_text(gui: dict, *needles: str, enabled: bool | None = None) -> dict | None: + lowered = tuple(n.lower() for n in needles) + candidates = [] + for button in gui.get("buttons", []): + if enabled is not None and bool(button.get("enabled", False)) != enabled: + continue + candidates.append(button) + if not lowered: + return candidates[0] if candidates else None + + for button in candidates: + text = str(button.get("text", "")).strip().lower() + if any(n == text for n in lowered): + return button + + for button in candidates: + text = str(button.get("text", "")).lower() + if not any(n in text for n in lowered): + continue + return button + return None + + +def _click_button(bridge: BridgeClient, *needles: str, enabled: bool | None = True) -> dict: + gui = bridge.gui() + button = _button_with_text(gui, *needles, enabled=enabled) + if button is None: + labels = [b.get("text", "") for b in gui.get("buttons", [])] + raise RuntimeError(f"No matching button {needles!r}; available={labels!r}") + return bridge.click(int(button["id"])) + + +def _first_text_field(gui: dict) -> dict | None: + fields = gui.get("textFields", []) + return fields[0] if fields else None + + def run_case( target: Target, scenario: dict, @@ -336,6 +378,8 @@ def _launch_client(ctx, target, client_image): game_dir = ctx["game_dir"] (game_dir / "mods").mkdir(parents=True, exist_ok=True) shutil.copy2(ctx["artifact"], game_dir / "mods" / "automodpack.jar") + (game_dir / "options.txt").write_text("narrator:0\n") + _bridge_state(ctx).unlink(missing_ok=True) # Per-target HMC cache (isolated to prevent concurrent NeoForge installer corruption) hmc_cache_root = (ctx["out_dir"].parent / ".hmc-cache" / target.id.replace(".", "_")).resolve() @@ -400,40 +444,19 @@ def _phase_wait_bridge(ctx): raise TimeoutError( f"Client exited before bridge: {e}\n--- logs ---\n{logs[-2000:]}" ) - if _bridge_state(ctx).exists(): - try: - ctx["bridge"].request("ping", timeout=5) - return - except Exception: - pass + try: + state_file = _bridge_state(ctx) + if state_file.exists(): + data = json.loads(state_file.read_text(encoding="utf-8")) + if data.get("status") == "ready": + ctx["bridge"].request("ping", timeout=5) + return + except Exception: + pass _jitter_sleep(1) raise TimeoutError(f"Bridge for {ctx['target'].id} did not become available within {to}s") -@_reg("ensure_ready") -def _phase_ensure_ready(ctx): - bridge = ctx["bridge"] - dl = time.monotonic() + 30 - while time.monotonic() < dl: - try: - r = bridge.request("get_widgets") - except (TimeoutError, RuntimeError): - _jitter_sleep(1) - continue - if "TitleScreen" in str(r.get("screenClass", "")) or "class_442" in str( - r.get("screenClass", "") - ): - return - if any("Continue" in str(w.get("text", "")) for w in r.get("widgets", [])): - try: - bridge.request("click", selector={"text": "Continue"}) - except (TimeoutError, RuntimeError): - pass - _jitter_sleep(1) - continue - _jitter_sleep(0.5) - - @_reg("read_fingerprint") def _phase_read_fingerprint(ctx): to = float(ctx["scenario"].get("timeouts", {}).get("serverStartSeconds", 180)) @@ -462,17 +485,17 @@ def _phase_connect(ctx): while time.monotonic() < deadline: _assert_running(ctx["cli_name"]) - bridge.request("connect", host=host, port=25565) + bridge.connect(host, 25565) remaining = deadline - time.monotonic() poll_dl = time.monotonic() + min(remaining, 45) while time.monotonic() < poll_dl: - screen = str(bridge.request("get_screen").get("screenClass") or "") + screen = str(bridge.gui().get("screenClass") or "") if any(n in screen for n in _TITLE): break if not any(n in screen for n in _CONNECT): return _jitter_sleep(0.5) - bridge.request("set_screen") + bridge.request("disconnect") _jitter_sleep(1) raise RuntimeError("Could not connect after multiple attempts") @@ -484,12 +507,16 @@ def _phase_wait_fingerprint(ctx): raise RuntimeError("No fingerprint — run read_fingerprint phase first") _await( lambda: ( - "FingerprintVerificationScreen" - in str(ctx["bridge"].request("get_screen").get("screenClass", "")) - or None + gui + if ( + (gui := ctx["bridge"].gui()) + and _first_text_field(gui) + and _button_with_text(gui, "verify", enabled=True) + ) + else None ), 180, - f"FingerprintVerificationScreen did not appear for {ctx['target'].id} within 180s", + f"Certificate verification prompt did not appear for {ctx['target'].id} within 180s", ) @@ -498,15 +525,19 @@ def _phase_accept_fingerprint(ctx): fp = ctx.get("fingerprint") if not fp: raise RuntimeError("No fingerprint — run read_fingerprint phase first") - ctx["bridge"].request("verify_fingerprint", fingerprint=fp) + bridge = ctx["bridge"] + gui = bridge.gui() + field = _first_text_field(gui) + if field is None: + raise RuntimeError("No fingerprint text field found") + bridge.text(int(field["id"]), fp) + _click_button(bridge, "verify") def _check(): - screen_class = str(ctx["bridge"].request("get_screen").get("screenClass", "")) - if any(n in screen_class for n in ("DangerScreen", "DownloadScreen", "RestartScreen")): - return True - if "FingerprintVerificationScreen" not in screen_class: - return True - return None + gui = bridge.gui() + if _button_with_text(gui, "verify"): + return None + return True _await(_check, 20, "Fingerprint verification did not complete") @@ -514,29 +545,27 @@ def _check(): @_reg("skip_fingerprint") def _phase_skip_fingerprint(ctx): bridge = ctx["bridge"] - bridge.request("click", selector={"text": "Skip"}) + _click_button(bridge, "skip") _await( lambda: ( - "SkipVerificationScreen" - in str(bridge.request("get_screen").get("screenClass", "")) - or None + gui + if ((gui := bridge.gui()) and _first_text_field(gui) and _button_with_text(gui, "skip")) + else None ), 15, "Skip screen not shown", ) - bridge.request( - "set_text", selector={"type": "EditBox", "index": 0}, text="I accept the risk" - ) + field = _first_text_field(bridge.gui()) + if field is None: + raise RuntimeError("No skip confirmation text field found") + bridge.text(int(field["id"]), "I accept the risk") dl = time.monotonic() + 30 while time.monotonic() < dl: - for w in bridge.request("get_widgets").get("widgets", []): - if ( - w.get("type") == "Button" - and "Skip" in str(w.get("text", "")) - and w.get("active", False) - ): - bridge.request("click", selector={"widgetId": w["id"]}) - return + gui = bridge.gui() + button = _button_with_text(gui, "skip", enabled=True) + if button: + bridge.click(int(button["id"])) + return _jitter_sleep(1) raise RuntimeError("Skip button did not activate") @@ -546,11 +575,10 @@ def _phase_wait_danger(ctx): bridge = ctx["bridge"] _await( lambda: ( - "DangerScreen" in str(bridge.request("get_screen").get("screenClass", "")) - or None + gui if ((gui := bridge.gui()) and _button_with_text(gui, "download", enabled=True)) else None ), 90, - "DangerScreen did not appear within 90s", + "Download confirmation did not appear within 90s", ) @@ -559,15 +587,14 @@ def _phase_click_confirm(ctx): bridge = ctx["bridge"] dl = time.monotonic() + 5 while time.monotonic() < dl: - widgets = bridge.request("get_widgets").get("widgets", []) - if widgets: + gui = bridge.gui() + if gui.get("buttons"): break _jitter_sleep(0.2) - for w in reversed(widgets): - if w.get("type") == "Button" and w.get("active", False): - bridge.request("click", widgetId=int(w.get("id", -1))) - return - raise RuntimeError("No active button on DangerScreen") + button = _button_with_text(gui, "download", enabled=True) + if button is None: + raise RuntimeError("No active download confirmation button") + bridge.click(int(button["id"])) @_reg("wait_download") @@ -628,21 +655,12 @@ def _phase_click_restart(ctx): dl = time.monotonic() + 20 while time.monotonic() < dl: try: - screen = bridge.request("get_screen") + gui = bridge.gui() except TimeoutError: continue - if "RestartScreen" in str(screen.get("screenClass", "")): - widgets = bridge.request("get_widgets").get("widgets", []) - clicked = False - action_labels = ("close", "restart", "quit") - for w in reversed(widgets): - txt = str(w.get("text", "")).lower() - if w.get("type") == "Button" and w.get("active", False) and any(label in txt for label in action_labels): - bridge.request("click", widgetId=int(w.get("id", -1))) - clicked = True - break - if not clicked: - raise RuntimeError("No restart button found on RestartScreen") + button = _button_with_text(gui, "close the game", "restart", "quit", enabled=True) + if button: + bridge.click(int(button["id"])) _wait_exited(ctx["cli_name"], timeout=90) return _jitter_sleep(0.5) @@ -680,19 +698,14 @@ def _phase_launch_client(ctx): def _phase_wait_join(ctx): bridge = ctx["bridge"] to = float(ctx["scenario"].get("timeouts", {}).get("rejoinSeconds", 180)) - - def _check(): - screen = bridge.request("get_screen") - screen_class = screen.get("screenClass") - if screen_class is None: - return True - name = str(screen_class) - if "FingerprintVerificationScreen" in name: - return None - if "DownloadScreen" in name: - return None - if "RestartScreen" in name: - return None - return True - - _await(_check, to, f"{ctx['target'].id}: Player did not join in-game within {to}s") + dl = time.monotonic() + to + while time.monotonic() < dl: + _assert_running(ctx["cli_name"]) + try: + gui = bridge.gui(timeout=10) + if gui.get("screenClass") is None: + return + except (TimeoutError, RuntimeError): + pass + _jitter_sleep(2) + raise TimeoutError(f"{ctx['target'].id}: Player did not join in-game within {to}s") diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index cb8a1a740..13808028c 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -70,11 +70,27 @@ hmc.exit.on.failed.command=true hmc.gameargs=$gameargs EOF -# ── Install Fabric loader if needed ─────────────────────────────── +# ── Install loader if needed ────────────────────────────────────── launch_target="${loader}:${minecraft}" if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then timeout 30 hmc --command "fabric ${minecraft} --uid ${loader_version}" 2>/dev/null || true launch_target="fabric-loader-${loader_version}-${minecraft}" +#elif [ "$loader" = "forge" ] && [ -n "$loader_version" ]; then +# timeout 30 hmc --command "forge ${minecraft} --uid ${loader_version}" 2>/dev/null || true +# discovered_target="$(discover_launch_target || true)" +# if [ -n "$discovered_target" ]; then +# launch_target="$discovered_target" +# else +# launch_target="${minecraft}-forge-${loader_version}" +# fi +#elif [ "$loader" = "neoforge" ] && [ -n "$loader_version" ]; then +# timeout 30 hmc --command "neoforge ${minecraft} --uid ${loader_version}" 2>/dev/null || true +# discovered_target="$(discover_launch_target || true)" +# if [ -n "$discovered_target" ]; then +# launch_target="$discovered_target" +# else +# launch_target="${minecraft}-neoforge-${loader_version}" +# fi fi # ── Disable Neo/Forge early display window (may cause crash in CI) ── diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml index 9a9fad007..348b89c4c 100644 --- a/autotester/scenarios/download-only.yaml +++ b/autotester/scenarios/download-only.yaml @@ -8,7 +8,6 @@ flow: - read_fingerprint - wait_server - wait_bridge - - ensure_ready - connect - wait_fingerprint - accept_fingerprint diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index 6787f8f68..b2ed55e02 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -9,7 +9,6 @@ flow: - read_fingerprint - wait_server - wait_bridge - - ensure_ready - connect - wait_fingerprint - accept_fingerprint @@ -21,7 +20,6 @@ flow: - quit - launch_client - wait_bridge - - ensure_ready - connect - wait_join - quit diff --git a/autotester/settings.yaml b/autotester/settings.yaml index a1321a54b..615c9d9c5 100644 --- a/autotester/settings.yaml +++ b/autotester/settings.yaml @@ -27,7 +27,8 @@ server: SPAWN_MONSTERS: "false" SPAWN_NPCS: "false" GENERATE_STRUCTURES: "false" - LEVEL_TYPE: "void" + LEVEL_TYPE: "flat" + GENERATOR_SETTINGS: "{\"lakes\":false,\"layers\":[{\"block\":\"minecraft:stone\",\"height\":1}],\"biome\":\"minecraft:plains\",\"structures\":{\"structures\":{}}}" SPAWN_RADIUS: "0" serverTypes: diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java index 0949b8ed3..fbfd9ac8e 100644 --- a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -8,33 +8,52 @@ import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.screens.ConnectScreen; +/*? if >=1.21.6 {*/ +import net.minecraft.client.gui.screens.GenericMessageScreen; +/*?}*/ import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; import net.minecraft.client.multiplayer.ServerData; import net.minecraft.client.multiplayer.resolver.ServerAddress; +/*? if >= 1.21.10 {*/ +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; +/*?}*/ /*? if >= 1.20.5 {*/ import net.minecraft.client.multiplayer.TransferState; /*?}*/ -import pl.skidam.automodpack.client.ui.FingerprintVerificationScreen; -import pl.skidam.automodpack_core.Constants; +/*? if >= 1.19.2 {*/ +import net.minecraft.network.chat.Component; +/*?} else {*/ +/*import net.minecraft.network.chat.TranslatableComponent; +*//*?}*/ import java.io.IOException; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import static pl.skidam.automodpack_core.Constants.LOGGER; public final class AutoTestBridge { private static final AtomicBoolean STARTED = new AtomicBoolean(false); - - private AutoTestBridge() {} + private static volatile Path bridgeDir; + private static final AtomicBoolean CLIENT_READY = new AtomicBoolean(false); public static void startIfEnabled() { if (!Boolean.getBoolean("automodpack.autotest")) return; @@ -45,21 +64,33 @@ public static void startIfEnabled() { LOGGER.warn("AutoModpack bridge disabled: token is '{}', gamedir is '{}'", token, gameDir); return; } + Thread t = new Thread(() -> run(Path.of(gameDir), token), "AutoModpackBridge"); t.setDaemon(true); t.start(); } + public static void onClientReady() { + if (!CLIENT_READY.compareAndSet(false, true)) return; + Path dir = bridgeDir; + if (dir == null) return; + try { + writeFile(dir.resolve("bridge-state.json"), "{\"status\":\"ready\"}"); + } catch (IOException e) { + LOGGER.error("Cannot write client-ready state", e); + } + } + private static void run(Path gameDir, String token) { Path dir = gameDir.resolve("automodpack/autotest"); - try { Files.createDirectories(dir); } catch (IOException e) { - LOGGER.error("Cannot create autotest dir", e); - return; - } - try { writeFile(dir.resolve("bridge-state.json"), "{\"status\":\"ready\"}"); } catch (IOException e) { - LOGGER.error("Cannot write bridge state", e); + bridgeDir = dir; + try { + Files.createDirectories(dir); + } catch (IOException e) { + LOGGER.error("Cannot initialize autotest bridge directory", e); return; } + LOGGER.info("AutoModpack bridge ready at {}", dir); Path cmd = dir.resolve("bridge-command.json"); Path rsp = dir.resolve("bridge-response.json"); @@ -68,181 +99,280 @@ private static void run(Path gameDir, String token) { if (Files.exists(cmd)) { String json = Files.readString(cmd, StandardCharsets.UTF_8); Files.delete(cmd); - String response; - try { - JsonObject req = JsonParser.parseString(json).getAsJsonObject(); - if (!token.equals(optString(req, "token"))) { - response = err("Authentication failed: invalid bridge token"); - } else { - response = exec(req); - } - } catch (Exception e) { - LOGGER.error("Bridge exec error", e); - response = err(e.getMessage()); - } - writeFile(rsp, response); + writeFile(rsp, handle(json, token)); } - Thread.sleep(100); - } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } - catch (Exception e) { LOGGER.error("Bridge error", e); } + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + LOGGER.error("AutoModpack bridge error", e); + } + } + } + + private static String handle(String json, String token) { + try { + JsonObject req = JsonParser.parseString(json).getAsJsonObject(); + if (!token.equals(optString(req, "token"))) { + return err("invalid bridge token"); + } + + return exec(req); + } catch (Exception e) { + LOGGER.error("AutoModpack bridge command failed", e); + return err(e.getMessage()); } } private static String exec(JsonObject req) throws Exception { return switch (optString(req, "op")) { case "ping" -> ok(); - case "get_screen" -> execOnMain(() -> scr(false)); - case "get_widgets" -> execOnMain(() -> scr(true)); - case "connect" -> connect(req); - case "wait_fingerprint" -> execOnMain(() -> ok()); - case "set_text" -> execOnMain(() -> { - Object w = widget(req); - if (w instanceof EditBox e) { - e.setValue(optString(req, "text")); - Screen s = Minecraft.getInstance().screen; - if (s instanceof FingerprintVerificationScreen fps) { - fps.setInputText(optString(req, "text")); - } - } - return ok(); - }); - case "click" -> execOnMain(() -> { - Object w = widget(req); - if (w instanceof Button b) { - /*? if >= 1.21.10 {*/ - var input = new net.minecraft.client.input.InputWithModifiers() { - public int input() { return 0; } - public int modifiers() { return 0; } - }; - b.onPress(input); - /*?} else {*/ - /*b.onPress(); - *//*?}*/ - } - return ok(); - }); - case "set_screen" -> execOnMain(() -> { Minecraft.getInstance().setScreen(new TitleScreen()); return ok(); }); - case "verify_fingerprint" -> execOnMain(() -> { - Screen s = Minecraft.getInstance().screen; - if (s instanceof FingerprintVerificationScreen fps) { - String fp = optString(req, "fingerprint"); - List widgets = collectWidgets(); - for (Object w : widgets) { - if (w instanceof EditBox e) { - e.setValue(fp); - break; - } - } - fps.setInputText(fp); - fps.verifyFingerprint(); - return ok(); - } - return err("not on FingerprintVerificationScreen"); - }); - case "quit" -> { - Minecraft.getInstance().execute(() -> Minecraft.getInstance().stop()); - yield ok(); - } - default -> err("Unknown bridge operation: '" + optString(req, "op") + "'"); + case "gui" -> onMain(() -> gui().toString()); + case "click" -> onMain(() -> click(req)); + case "text" -> onMain(() -> text(req)); + case "menu" -> onMain(AutoTestBridge::menu); + case "close" -> onMain(AutoTestBridge::close); + case "connect" -> onMain(() -> connect(req)); + case "disconnect" -> onMain(AutoTestBridge::disconnect); + case "quit" -> onMain(AutoTestBridge::quit); + case "render" -> render(req); + default -> err("unknown operation: " + optString(req, "op")); }; } - private static String scr(boolean detailed) { - Screen s = Minecraft.getInstance().screen; - JsonObject o = new JsonObject(); - o.addProperty("ok", true); + private static JsonObject gui() { + Minecraft c = Minecraft.getInstance(); + Screen s = c.screen; + JsonObject o = base(); o.addProperty("screenClass", s == null ? null : s.getClass().getName()); o.addProperty("title", s == null ? null : s.getTitle().getString()); - if (detailed && s != null) { + o.add("buttons", elementsJson(elements(s).buttons())); + o.add("textFields", elementsJson(elements(s).textFields())); + o.add("other", elementsJson(elements(s).other())); + o.add("elements", elementsJson(elements(s).all())); + return o; + } + + private static String click(JsonObject req) { + Minecraft c = Minecraft.getInstance(); + Screen s = c.screen; + if (s == null) return err("no screen"); + + int button = optInt(req, "button", 0); + int x; + int y; + if (has(req, "id")) { + GuiElement e = elements(s).byId(optInt(req, "id", -1)); + if (e == null) return err("no gui element with id " + optInt(req, "id", -1)); + if (has(req, "enable") && req.get("enable").getAsBoolean() && e.widget() instanceof Button) { + e.widget().active = true; + } + x = e.x() + e.width() / 2; + y = e.y() + e.height() / 2; + } else { + x = optInt(req, "x", -1); + y = optInt(req, "y", -1); + if (x < 0 || y < 0) return err("click needs either id or x/y"); + } + + s.mouseMoved(x, y); + /*? if >= 1.21.10 {*/ + MouseButtonEvent event = new MouseButtonEvent(x, y, new MouseButtonInfo(button, 0)); + s.mouseClicked(event, false); + s.mouseReleased(event); + /*?} else {*/ + /*s.mouseClicked(x, y, button); + s.mouseReleased(x, y, button); + *//*?}*/ + return ok(); + } + + private static String text(JsonObject req) { + Screen s = Minecraft.getInstance().screen; + if (s == null) return err("no screen"); + + int id = optInt(req, "id", -1); + GuiElement e = elements(s).byId(id); + if (e == null || !(e.widget() instanceof EditBox editBox)) { + return err("no text field with id " + id); + } + + editBox.setValue(optString(req, "text")); + return ok(); + } + + private static String menu() { + Minecraft c = Minecraft.getInstance(); + if (c.player == null) return err("not in game"); + if (c.screen != null) c.screen.onClose(); + c.pauseGame(false); + return ok(); + } + + private static String close() { + Minecraft c = Minecraft.getInstance(); + if (c.player == null) return err("not in game"); + if (c.screen != null) c.screen.onClose(); + return ok(); + } + + private static String connect(JsonObject req) { + Minecraft c = Minecraft.getInstance(); + String host = optString(req, "host"); + int port = optInt(req, "port", 25565); + if (host.isBlank()) return err("host is required"); + + ServerAddress address = ServerAddress.parseString(host + ":" + port); + ServerData serverData = new ServerData("AutoTest", address.toString() + /*? if >= 1.20.4 {*/, ServerData.Type.OTHER/*?} else {*//*, false*//*?}*/); + /*? if >= 1.20.5 {*/ + ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData, false, (TransferState) null); + /*?} else if >= 1.20.4 {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData, false); + *//*?} else if >= 1.20.1 {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData, false); + *//*?} else {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData); + *//*?}*/ + return ok(); + } + + private static String disconnect() { + Minecraft c = Minecraft.getInstance(); + if (c.level == null) { + c.setScreen(new TitleScreen()); + return ok(); + } + + /*? if >=1.21.6 {*/ + c.level.disconnect(translatable("multiplayer.status.quitting")); + c.clearClientLevel(new GenericMessageScreen(translatable("multiplayer.disconnect.generic"))); + /*?} else {*/ + /*c.level.disconnect(); + *//*?}*/ + c.setScreen(new TitleScreen()); + return ok(); + } + + private static String quit() { + Minecraft.getInstance().stop(); + return ok(); + } + + private static String render(JsonObject req) throws InterruptedException { + int millis = Math.max(1, optInt(req, "time", 1000)); + boolean includeDuplicates = has(req, "includeDuplicates") && req.get("includeDuplicates").getAsBoolean(); + RenderedTextCollector.Session session = RenderedTextCollector.start(); + try { + Thread.sleep(millis); + JsonObject o = base(); JsonArray a = new JsonArray(); - int i = 0; - for (Object w : collectWidgets()) { - if (!(w instanceof AbstractWidget aw)) continue; - JsonObject wo = new JsonObject(); - wo.addProperty("id", i++); - wo.addProperty("type", aw instanceof Button ? "Button" : aw instanceof EditBox ? "EditBox" : aw.getClass().getSimpleName()); - wo.addProperty("class", aw.getClass().getName()); - wo.addProperty("text", aw.getMessage().getString()); - /*? if >= 1.19.4 {*/ - wo.addProperty("x", aw.getX()); wo.addProperty("y", aw.getY()); - /*?} else {*/ - /*wo.addProperty("x", aw.x); wo.addProperty("y", aw.y); - *//*?}*/ - wo.addProperty("active", aw.active); wo.addProperty("visible", aw.visible); - a.add(wo); + for (RenderedTextCollector.Entry entry : session.entries(includeDuplicates)) { + JsonObject e = new JsonObject(); + e.addProperty("text", entry.text()); + e.addProperty("x", entry.x()); + e.addProperty("y", entry.y()); + a.add(e); } - o.add("widgets", a); + o.add("strings", a); + return o.toString(); + } finally { + session.close(); } - return o.toString(); } - private static String connect(JsonObject req) throws Exception { - String addr = optString(req, "host") + ":" + optInt(req, "port", 25565); - Minecraft c; - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); - while ((c = Minecraft.getInstance()) == null) { - if (System.nanoTime() > deadline) return err("Minecraft not initialized"); - Thread.sleep(100); + private static GuiElements elements(Screen screen) { + if (screen == null) return new GuiElements(List.of()); + + LinkedHashSet widgets = new LinkedHashSet<>(); + for (GuiEventListener child : screen.children()) { + if (child instanceof AbstractWidget widget) widgets.add(widget); } - final Minecraft captured = c; - if (captured.getOverlay() != null) { - deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120); - while (captured.getOverlay() != null && System.nanoTime() < deadline) Thread.sleep(100); + findWidgets(screen, widgets, newSeenSet()); + + List result = new ArrayList<>(); + int id = 0; + for (AbstractWidget widget : widgets) { + result.add(new GuiElement(id++, widget)); } - CompletableFuture f = new CompletableFuture<>(); - captured.execute(() -> { - try { - /*? if >= 1.20.5 {*/ - ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, ServerData.Type.OTHER), false, (TransferState) null); - /*?} else if >= 1.20.4 {*/ - /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, ServerData.Type.OTHER), false); - *//*?} else if >= 1.20.1 {*/ - /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, false), false); - *//*?} else {*/ - /*ConnectScreen.startConnecting(new TitleScreen(), captured, ServerAddress.parseString(addr), new ServerData("AutoTest", addr, false)); - *//*?}*/ - f.complete(ok()); - } catch (Exception e) { f.complete(err(e.getMessage())); } - }); - return f.get(30, TimeUnit.SECONDS); + return new GuiElements(result); } - private static List collectWidgets() { - Screen s = Minecraft.getInstance().screen; - if (s == null) return List.of(); - return List.copyOf(s.children().stream().filter(w -> w instanceof AbstractWidget).toList()); - } - - private static Object widget(JsonObject req) { - List all = collectWidgets(); - if (all.isEmpty()) throw new NullPointerException("no widgets"); - int wid = optInt(req, "widgetId", -1); - JsonObject sel = req.getAsJsonObject("selector"); - if (wid < 0 && sel != null) wid = optInt(sel, "widgetId", -1); - if (wid >= 0 && wid < all.size()) return all.get(wid); - String selType = sel != null ? optString(sel, "type") : null; - String selText = sel != null ? optString(sel, "text") : optString(req, "text"); - int idx = sel != null ? optInt(sel, "index", -1) : -1; - var cand = selType != null && !selType.isEmpty() ? all.stream().filter(w -> (w instanceof Button ? "Button" : w instanceof EditBox ? "EditBox" : "").equalsIgnoreCase(selType)).toList() : all; - if (selText != null && !selText.isEmpty()) { - for (Object w : cand) { if (AbstractWidget.class.cast(w).getMessage().getString().equalsIgnoreCase(selText)) return w; } - for (Object w : cand) { if (AbstractWidget.class.cast(w).getMessage().getString().toLowerCase().contains(selText.toLowerCase())) return w; } + private static void findWidgets(Object object, Set widgets, Set seen) { + if (object == null || seen.contains(object)) return; + seen.add(object); + + Class type = object.getClass(); + while (type != null && type != Object.class) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + try { + field.setAccessible(true); + collectWidgetValue(field.get(object), widgets, seen); + } catch (ReflectiveOperationException | RuntimeException ignored) { + // Best effort only. Screen#children is the primary source. + } + } + type = type.getSuperclass(); } - if (idx >= 0 && idx < cand.size()) return cand.get(idx); - if (!cand.isEmpty()) return cand.get(0); - throw new IllegalArgumentException("widget not found"); } - private static String execOnMain(ThrowingSupplier t) throws Exception { - Minecraft c; - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); - while ((c = Minecraft.getInstance()) == null) { - if (System.nanoTime() > deadline) return err("Minecraft not initialized"); - Thread.sleep(100); + private static void collectWidgetValue(Object value, Set widgets, Set seen) { + if (value == null) return; + if (value instanceof AbstractWidget widget) { + widgets.add(widget); + return; + } + if (value instanceof Collection collection) { + for (Object item : collection) collectWidgetValue(item, widgets, seen); + return; + } + if (value instanceof Map map) { + for (Object item : map.values()) collectWidgetValue(item, widgets, seen); + return; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) collectWidgetValue(Array.get(value, i), widgets, seen); } - CompletableFuture f = new CompletableFuture<>(); - c.execute(() -> { try { f.complete(t.get()); } catch (Exception e) { f.completeExceptionally(e); } }); - return f.get(60, TimeUnit.SECONDS); + } + + private static JsonArray elementsJson(List elements) { + JsonArray a = new JsonArray(); + for (GuiElement e : elements) { + JsonObject o = new JsonObject(); + o.addProperty("id", e.id()); + o.addProperty("text", e.text()); + o.addProperty("x", e.x()); + o.addProperty("y", e.y()); + o.addProperty("width", e.width()); + o.addProperty("height", e.height()); + o.addProperty("enabled", e.widget().active); + o.addProperty("visible", e.widget().visible); + o.addProperty("type", e.type()); + o.addProperty("class", e.widget().getClass().getName()); + a.add(o); + } + return a; + } + + private static Set newSeenSet() { + return java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + } + + private static String onMain(ThrowingSupplier supplier) throws Exception { + Minecraft c = Minecraft.getInstance(); + CompletableFuture f = new CompletableFuture<>(); + c.execute(() -> { + try { + f.complete(supplier.get()); + } catch (Exception e) { + f.completeExceptionally(e); + } + }); + T result = f.get(); + return result instanceof String string ? string : String.valueOf(result); } private static void writeFile(Path p, String c) throws IOException { @@ -251,11 +381,107 @@ private static void writeFile(Path p, String c) throws IOException { Files.move(t, p, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } - private static String ok() { return "{\"ok\":true}"; } - private static String err(String m) { return "{\"ok\":false,\"error\":\"" + (m != null ? m.replace("\\", "\\\\").replace("\"", "\\\"") : "unknown") + "\"}"; } - private static String optString(JsonObject o, String k) { JsonElement e = o.get(k); return e != null && !e.isJsonNull() ? e.getAsString() : ""; } - private static int optInt(JsonObject o, String k, int d) { JsonElement e = o.get(k); return e != null && !e.isJsonNull() ? e.getAsInt() : d; } + private static JsonObject base() { + JsonObject o = new JsonObject(); + o.addProperty("ok", true); + return o; + } + + private static String ok() { + return "{\"ok\":true}"; + } + + private static String err(String m) { + JsonObject o = new JsonObject(); + o.addProperty("ok", false); + o.addProperty("error", m == null ? "unknown" : m); + return o.toString(); + } + + private static boolean has(JsonObject o, String k) { + JsonElement e = o.get(k); + return e != null && !e.isJsonNull(); + } + + private static String optString(JsonObject o, String k) { + JsonElement e = o.get(k); + return e != null && !e.isJsonNull() ? e.getAsString() : ""; + } + + private static int optInt(JsonObject o, String k, int d) { + JsonElement e = o.get(k); + return e != null && !e.isJsonNull() ? e.getAsInt() : d; + } + + /*? if >= 1.19.2 {*/ + private static Component translatable(String key) { + return Component.translatable(key); + } + /*?} else {*/ + /*private static TranslatableComponent translatable(String key) { + return new TranslatableComponent(key); + } + *//*?}*/ + + private record GuiElements(List all) { + GuiElement byId(int id) { + for (GuiElement element : all) { + if (element.id() == id) return element; + } + return null; + } + + List buttons() { + return all.stream().filter(e -> e.widget() instanceof Button).toList(); + } + + List textFields() { + return all.stream().filter(e -> e.widget() instanceof EditBox).toList(); + } + + List other() { + return all.stream().filter(e -> !(e.widget() instanceof Button) && !(e.widget() instanceof EditBox)).toList(); + } + } + + private record GuiElement(int id, AbstractWidget widget) { + String text() { + return widget instanceof EditBox editBox ? editBox.getValue() : widget.getMessage().getString(); + } + + int x() { + /*? if >= 1.19.4 {*/ + return widget.getX(); + /*?} else {*/ + /*return widget.x; + *//*?}*/ + } + + int y() { + /*? if >= 1.19.4 {*/ + return widget.getY(); + /*?} else {*/ + /*return widget.y; + *//*?}*/ + } + + int width() { + return widget.getWidth(); + } + + int height() { + return widget.getHeight(); + } + + String type() { + if (widget instanceof Button) return "Button"; + if (widget instanceof EditBox) return "TextField"; + return widget.getClass().getSimpleName(); + } + } @FunctionalInterface - private interface ThrowingSupplier { T get() throws Exception; } + private interface ThrowingSupplier { + T get() throws Exception; + } } diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java b/src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java new file mode 100644 index 000000000..32020f2cd --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java @@ -0,0 +1,16 @@ +package pl.skidam.automodpack.client.autotest; + +import net.minecraft.util.FormattedCharSequence; + +public final class FormattedText { + private FormattedText() {} + + public static String toString(FormattedCharSequence sequence) { + StringBuilder builder = new StringBuilder(); + sequence.accept((index, style, codePoint) -> { + builder.append((char) codePoint); + return true; + }); + return builder.toString(); + } +} diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java b/src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java new file mode 100644 index 000000000..b17533df9 --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java @@ -0,0 +1,52 @@ +package pl.skidam.automodpack.client.autotest; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class RenderedTextCollector { + private static final CopyOnWriteArrayList SESSIONS = new CopyOnWriteArrayList<>(); + + private RenderedTextCollector() {} + + public static Session start() { + Session session = new Session(); + SESSIONS.add(session); + return session; + } + + public static void record(String text, float x, float y) { + if (text == null || text.isBlank() || SESSIONS.isEmpty()) return; + Entry entry = new Entry(text, x, y); + for (Session session : SESSIONS) { + session.record(entry); + } + } + + public record Entry(String text, float x, float y) {} + + public static final class Session implements AutoCloseable { + private final CopyOnWriteArrayList entries = new CopyOnWriteArrayList<>(); + + private void record(Entry entry) { + entries.add(entry); + } + + public List entries(boolean includeDuplicates) { + if (includeDuplicates) return new ArrayList<>(entries); + + Map unique = new LinkedHashMap<>(); + for (Entry entry : entries) { + unique.putIfAbsent(entry.text() + "\u0000" + entry.x() + "\u0000" + entry.y(), entry); + } + return new ArrayList<>(unique.values()); + } + + @Override + public void close() { + SESSIONS.remove(this); + } + } +} diff --git a/src/main/java/pl/skidam/automodpack/init/FabricInit.java b/src/main/java/pl/skidam/automodpack/init/FabricInit.java index 93998cb9c..dde2ec349 100644 --- a/src/main/java/pl/skidam/automodpack/init/FabricInit.java +++ b/src/main/java/pl/skidam/automodpack/init/FabricInit.java @@ -29,7 +29,6 @@ public static void onInitialize() { } else { ModPackets.registerC2SPackets(); new AudioManager(); - AutoTestBridge.startIfEnabled(); } CommandRegistrationCallback.EVENT.register((dispatcher, /*? if >=1.19.1 {*/ w, /*?}*/ dedicated) -> { diff --git a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java index 9dd7a0085..f965f6603 100644 --- a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java @@ -33,7 +33,6 @@ public ForgeInit() { } else { ModPackets.registerC2SPackets(); new AudioManager(FMLJavaModLoadingContext.get().getModEventBus()); - AutoTestBridge.startIfEnabled(); } diff --git a/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java b/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java index 3f79a0d40..15c2c3bee 100644 --- a/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java @@ -35,7 +35,6 @@ public NeoForgeInit(IEventBus eventBus) { } else { ModPackets.registerC2SPackets(); new AudioManager(eventBus); - AutoTestBridge.startIfEnabled(); } diff --git a/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java b/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java index b58bd9607..ed854e335 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java @@ -8,6 +8,8 @@ import java.util.Set; public class MixinPlugin implements IMixinConfigPlugin { + private static final boolean AUTOTEST_ENABLED = Boolean.getBoolean("automodpack.autotest"); + @Override public void onLoad(String mixinPackage) { // Needed for versions < 1.18 @@ -21,6 +23,9 @@ public String getRefMapperConfig() { @Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + if (mixinClassName.contains(".dev.")) { + return AUTOTEST_ENABLED; + } return true; } diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java new file mode 100644 index 000000000..73ba0022d --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java @@ -0,0 +1,107 @@ +package pl.skidam.automodpack.mixin.dev; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.util.FormattedCharSequence; +/*? if >=26.1 {*/ +import net.minecraft.network.chat.Component; +import org.joml.Matrix4fc; +/*?} else if >=1.19.3 {*/ +/*import org.joml.Matrix4f; +*//*?} else {*/ +/*import com.mojang.math.Matrix4f; +*//*?}*/ +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import pl.skidam.automodpack.client.autotest.FormattedText; +import pl.skidam.automodpack.client.autotest.RenderedTextCollector; + +@Mixin(Font.class) +public abstract class FontRenderMixin { + /*? if >=26.1 {*/ + @Inject( + method = "drawInBatch(Ljava/lang/String;FFIZLorg/joml/Matrix4fc;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)V", + at = @At("HEAD"), + require = 0) + private void automodpack$drawString(String text, float x, float y, int color, boolean shadow, Matrix4fc pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfo ci) { + RenderedTextCollector.record(text, x, y); + } + + @Inject( + method = "drawInBatch(Lnet/minecraft/network/chat/Component;FFIZLorg/joml/Matrix4fc;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)V", + at = @At("HEAD"), + require = 0) + private void automodpack$drawComponent(Component text, float x, float y, int color, boolean shadow, Matrix4fc pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfo ci) { + RenderedTextCollector.record(FormattedText.toString(text.getVisualOrderText()), x, y); + } + + @Inject( + method = "drawInBatch(Lnet/minecraft/util/FormattedCharSequence;FFIZLorg/joml/Matrix4fc;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)V", + at = @At("HEAD"), + require = 0) + private void automodpack$drawSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, Matrix4fc pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfo ci) { + RenderedTextCollector.record(FormattedText.toString(text), x, y); + } + + @Inject(method = "drawInBatch8xOutline", at = @At("HEAD"), require = 0) + private void automodpack$drawOutline(FormattedCharSequence text, float x, float y, int color, int outlineColor, Matrix4fc pose, MultiBufferSource source, int light, CallbackInfo ci) { + RenderedTextCollector.record(FormattedText.toString(text), x, y); + } + + @Inject( + method = "prepareText(Ljava/lang/String;FFIZI)Lnet/minecraft/client/gui/Font$PreparedText;", + at = @At("HEAD"), + require = 0) + private void automodpack$prepareString(String text, float x, float y, int color, boolean shadow, int background, CallbackInfoReturnable cir) { + RenderedTextCollector.record(text, x, y); + } + + @Inject( + method = "prepareText(Lnet/minecraft/util/FormattedCharSequence;FFIZZI)Lnet/minecraft/client/gui/Font$PreparedText;", + at = @At("HEAD"), + require = 0) + private void automodpack$prepareSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, boolean includeEmpty, int background, CallbackInfoReturnable cir) { + RenderedTextCollector.record(FormattedText.toString(text), x, y); + } + /*?} else if >=1.19.3 {*/ + /*@Inject( + method = "renderText(Lnet/minecraft/util/FormattedCharSequence;FFIZLorg/joml/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)F", + at = @At("HEAD"), + require = 0) + private void automodpack$renderSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfoReturnable cir) { + RenderedTextCollector.record(FormattedText.toString(text), x, y); + } + + @Inject( + method = "renderText(Ljava/lang/String;FFIZLorg/joml/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)F", + at = @At("HEAD"), + require = 0) + private void automodpack$renderString(String text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfoReturnable cir) { + RenderedTextCollector.record(text, x, y); + } + + @Inject(method = "drawInBatch8xOutline", at = @At("HEAD"), require = 0) + private void automodpack$drawOutline(FormattedCharSequence text, float x, float y, int color, int outlineColor, Matrix4f pose, MultiBufferSource source, int light, CallbackInfo ci) { + RenderedTextCollector.record(FormattedText.toString(text), x, y); + } + *//*?} else {*/ + /*@Inject( + method = "renderText(Lnet/minecraft/util/FormattedCharSequence;FFIZLcom/mojang/math/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;ZII)F", + at = @At("HEAD"), + require = 0) + private void automodpack$renderSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, boolean seeThrough, int background, int light, CallbackInfoReturnable cir) { + RenderedTextCollector.record(FormattedText.toString(text), x, y); + } + + @Inject( + method = "renderText(Ljava/lang/String;FFIZLcom/mojang/math/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;ZII)F", + at = @At("HEAD"), + require = 0) + private void automodpack$renderString(String text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, boolean seeThrough, int background, int light, CallbackInfoReturnable cir) { + RenderedTextCollector.record(text, x, y); + } + *//*?}*/ +} diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java new file mode 100644 index 000000000..909abf35c --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java @@ -0,0 +1,41 @@ +package pl.skidam.automodpack.mixin.dev; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.main.GameConfig; +import org.slf4j.Logger; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; + +@Mixin(Minecraft.class) +public abstract class MinecraftMixin { + + @Inject(method = "", at = @At("RETURN")) + private void onInit(GameConfig gameConfig, CallbackInfo ci) { + AutoTestBridge.startIfEnabled(); + + Thread t = new Thread(() -> { + while (true) { + try { + Thread.sleep(100); + Minecraft mc = Minecraft.getInstance(); + if (mc.screen instanceof TitleScreen) { + System.out.println("AutoModpack: Client is ready, TitleScreen detected"); + AutoTestBridge.onClientReady(); + return; + } else { + System.out.println("AutoModpack: Waiting for TitleScreen, current screen: " + (mc.screen == null ? "null" : mc.screen.getClass().getName())); + } + } catch (Exception ignored) { + } + } + }, "AutoModpackReadyWaiter"); + t.setDaemon(true); + t.start(); + } +} diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java b/src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java deleted file mode 100644 index 5196eaf62..000000000 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java +++ /dev/null @@ -1,50 +0,0 @@ -package pl.skidam.automodpack.mixin.dev; - -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import pl.skidam.automodpack.client.audio.AudioManager; -import pl.skidam.automodpack.client.ui.versioned.VersionedScreen; -import pl.skidam.automodpack.client.ui.versioned.VersionedText; -import pl.skidam.automodpack_loader_core.screen.ScreenManager; - -import static pl.skidam.automodpack_core.Constants.LOADER_MANAGER; - -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.network.chat.Component; - -@Mixin(TitleScreen.class) -public class TestButton extends Screen { - - protected TestButton(Component title) { - super(title); - } - - @Inject( - method = "init", - at = @At("HEAD") - ) - private void init(CallbackInfo ci) { - - // check if we are in dev environment - if (!LOADER_MANAGER.isDevelopmentEnvironment()) { - return; - } - - this.addRenderableWidget( - VersionedScreen.buttonWidget( - this.width / 2 - 124, - 90, - 20, - 20, - VersionedText.literal("AM"), - button -> { - AudioManager.playMusic(); - new ScreenManager().menu(); - } - ) - ); - } -} diff --git a/src/main/resources/automodpack-main.mixins.json b/src/main/resources/automodpack-main.mixins.json index 36fcdff38..aa3b0403c 100644 --- a/src/main/resources/automodpack-main.mixins.json +++ b/src/main/resources/automodpack-main.mixins.json @@ -19,7 +19,9 @@ "core.ClientLoginNetworkHandlerAccessor", "core.ClientLoginNetworkHandlerMixin", "core.ConnectScreenMixin", - "core.MusicTrackerMixin" + "core.MusicTrackerMixin", + "dev.FontRenderMixin", + "dev.MinecraftMixin" ], "injectors": { "defaultRequire": 1 diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index 3b310e8f0..1a6db28f2 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -62,7 +62,7 @@ publish_versions = "1.21.6\n1.21.7\n1.21.8" deps.fabric-api = "0.128.2+1.21.5" ["1.21.5-neoforge"] -deps.neoforge = "21.5.75" +deps.neoforge = "21.5.97" ["1.21.5"] deps.minecraft = "1.21.5" @@ -74,7 +74,7 @@ publish_versions = "1.21.5" deps.fabric-api = "0.119.4+1.21.4" ["1.21.4-neoforge"] -deps.neoforge = "21.4.135" +deps.neoforge = "21.4.157" ["1.21.4"] deps.minecraft = "1.21.4" From 6385a2e1ba375d9fdee4097fa4c181faa7450239 Mon Sep 17 00:00:00 2001 From: skidam Date: Tue, 2 Jun 2026 02:09:51 +0200 Subject: [PATCH 35/44] wait for resource load properly --- .../client/autotest/AutoTestBridge.java | 28 +++++++++++++++++++ .../automodpack/mixin/dev/MinecraftMixin.java | 23 --------------- .../dev/ResourceLoadStateTrackerMixin.java | 24 ++++++++++++++++ .../resources/automodpack-main.mixins.json | 3 +- 4 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java index fbfd9ac8e..8deeaef6a 100644 --- a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -54,6 +54,15 @@ public final class AutoTestBridge { private static final AtomicBoolean STARTED = new AtomicBoolean(false); private static volatile Path bridgeDir; private static final AtomicBoolean CLIENT_READY = new AtomicBoolean(false); + private static volatile boolean reloadFinished = false; + + public static void markReloadFinished() { + reloadFinished = true; + } + + public static boolean hasReloadFinished() { + return reloadFinished; + } public static void startIfEnabled() { if (!Boolean.getBoolean("automodpack.autotest")) return; @@ -68,6 +77,25 @@ public static void startIfEnabled() { Thread t = new Thread(() -> run(Path.of(gameDir), token), "AutoModpackBridge"); t.setDaemon(true); t.start(); + + Thread waiter = new Thread(() -> { + while (true) { + try { + Thread.sleep(100); + Minecraft mc = Minecraft.getInstance(); + if (mc.screen instanceof TitleScreen && hasReloadFinished()) { + LOGGER.info("AutoModpack: Client is ready, TitleScreen detected"); + onClientReady(); + return; + } else { + LOGGER.info("AutoModpack: Waiting for TitleScreen, current screen: {}", mc.screen == null ? "null" : mc.screen.getClass().getName()); + } + } catch (Exception ignored) { + } + } + }, "AutoModpackReadyWaiter"); + waiter.setDaemon(true); + waiter.start(); } public static void onClientReady() { diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java index 909abf35c..f427dc2e2 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java @@ -1,12 +1,8 @@ package pl.skidam.automodpack.mixin.dev; import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.screens.TitleScreen; import net.minecraft.client.main.GameConfig; -import org.slf4j.Logger; -import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @@ -18,24 +14,5 @@ public abstract class MinecraftMixin { @Inject(method = "", at = @At("RETURN")) private void onInit(GameConfig gameConfig, CallbackInfo ci) { AutoTestBridge.startIfEnabled(); - - Thread t = new Thread(() -> { - while (true) { - try { - Thread.sleep(100); - Minecraft mc = Minecraft.getInstance(); - if (mc.screen instanceof TitleScreen) { - System.out.println("AutoModpack: Client is ready, TitleScreen detected"); - AutoTestBridge.onClientReady(); - return; - } else { - System.out.println("AutoModpack: Waiting for TitleScreen, current screen: " + (mc.screen == null ? "null" : mc.screen.getClass().getName())); - } - } catch (Exception ignored) { - } - } - }, "AutoModpackReadyWaiter"); - t.setDaemon(true); - t.start(); } } diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java new file mode 100644 index 000000000..3adf9a155 --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java @@ -0,0 +1,24 @@ +package pl.skidam.automodpack.mixin.dev; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.ResourceLoadStateTracker; +import net.minecraft.client.gui.screens.TitleScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; + +@Mixin(ResourceLoadStateTracker.class) +public class ResourceLoadStateTrackerMixin { + + @Inject(method = "finishReload", at = @At("RETURN")) + private void onFinishReload(CallbackInfo ci) { + AutoTestBridge.markReloadFinished(); + Minecraft mc = Minecraft.getInstance(); + if (mc.screen instanceof TitleScreen) { + System.out.println("AutoModpack: Client is ready, reload finished, TitleScreen detected"); + AutoTestBridge.onClientReady(); + } + } +} diff --git a/src/main/resources/automodpack-main.mixins.json b/src/main/resources/automodpack-main.mixins.json index aa3b0403c..5b9cf9b9f 100644 --- a/src/main/resources/automodpack-main.mixins.json +++ b/src/main/resources/automodpack-main.mixins.json @@ -21,7 +21,8 @@ "core.ConnectScreenMixin", "core.MusicTrackerMixin", "dev.FontRenderMixin", - "dev.MinecraftMixin" + "dev.MinecraftMixin", + "dev.ResourceLoadStateTrackerMixin" ], "injectors": { "defaultRequire": 1 From 335eb642cf0b59fae848185e13c722564684c500 Mon Sep 17 00:00:00 2001 From: skidam Date: Thu, 25 Jun 2026 17:12:31 +0200 Subject: [PATCH 36/44] =?UTF-8?q?autotester:=20review=20pass=20=E2=80=94?= =?UTF-8?q?=20fix=20login-flow=20regression,=20remove=20dead=20code,=20cle?= =?UTF-8?q?an=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production fixes: - DataC2SPacket: restore original flow so the client secret is still saved when the modpack content can't be fetched but the host is reachable (the async rewrite had dropped that case via an early return). - DownloadClient: remove the now-dead sync constructor + establishProbeConnection /recoverProbeConnection (superseded by createAsync). - Rename ModpackUpdater.CheckAndLoadModpack -> checkAndLoadModpack. - Revert the whole-file spaces->tabs reindentation of Preload.java; keep only the real change (load installed modpack when updateSelectedModpackOnLaunch is off). - Normalize stray tabs -> spaces (DataC2SPacket, ModpackUtils, ScreenImpl, ClientLoginNetworkAddon). - Drop unused AutoTestBridge imports from Fabric/Forge/NeoForge init. Autotester: - Remove the unused render/menu/close bridge ops and their backing code (FontRenderMixin, RenderedTextCollector, FormattedText, mixins.json entry). - Remove unused BridgeClient helpers (buttons, text_fields, click_point). - Clearer phase names: wait_danger -> wait_download_prompt, click_confirm -> confirm_download, click_restart -> confirm_restart. - Quiet the dev mixins: single client-ready log in onClientReady(), drop the 100ms INFO spam and System.out.println. - cli.py: move the run() return out of the finally block so real errors aren't swallowed. - README: correct phase table and bridge-op list; document skip_fingerprint and verify_mods. --- autotester/README.md | 20 +- autotester/automodpack_autotester/bridge.py | 9 - autotester/automodpack_autotester/cli.py | 3 +- autotester/automodpack_autotester/runner.py | 12 +- autotester/scenarios/download-only.yaml | 4 +- autotester/scenarios/sync.yaml | 6 +- .../protocol/DownloadClient.java | 50 -- .../automodpack_loader_core/Preload.java | 463 +++++++++--------- .../client/ModpackUpdater.java | 12 +- .../client/ModpackUtils.java | 10 +- .../skidam/automodpack/client/ScreenImpl.java | 2 +- .../client/autotest/AutoTestBridge.java | 59 +-- .../client/autotest/FormattedText.java | 16 - .../autotest/RenderedTextCollector.java | 52 -- .../skidam/automodpack/init/FabricInit.java | 1 - .../pl/skidam/automodpack/init/ForgeInit.java | 1 - .../skidam/automodpack/init/NeoForgeInit.java | 1 - .../mixin/dev/FontRenderMixin.java | 107 ---- .../automodpack/mixin/dev/MinecraftMixin.java | 8 +- .../dev/ResourceLoadStateTrackerMixin.java | 16 +- .../client/ClientLoginNetworkAddon.java | 2 +- .../networking/packet/DataC2SPacket.java | 59 +-- .../resources/automodpack-main.mixins.json | 1 - 23 files changed, 315 insertions(+), 599 deletions(-) delete mode 100644 src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java delete mode 100644 src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java delete mode 100644 src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java diff --git a/autotester/README.md b/autotester/README.md index 7fb649b77..ad295636e 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -93,8 +93,8 @@ flow: - connect - wait_fingerprint - accept_fingerprint - - wait_danger - - click_confirm + - wait_download_prompt + - confirm_download - wait_download - verify_files - quit @@ -120,16 +120,18 @@ Useful phases: | `launch_server` | Start the Minecraft server container. | | `wait_server` | Wait until the server logs `Done (`. | | `launch_client` | Start a HeadlessMC client container. | -| `wait_bridge` | Wait for bridge + client-ready signal from MinecraftMixin. | +| `wait_bridge` | Wait for the in-game bridge to report the client is ready. | | `connect` | Connect the client to the test server. | | `read_fingerprint` | Extract the AutoModpack TLS fingerprint from server logs. | | `wait_fingerprint` | Wait for a certificate prompt with a text field and Verify button. | | `accept_fingerprint` | Enter the expected fingerprint and click Verify. | -| `wait_danger` | Wait for the download confirmation prompt. | -| `click_confirm` | Confirm the sync/update. | +| `skip_fingerprint` | Skip certificate verification (accept the risk) instead of verifying. | +| `wait_download_prompt` | Wait for the modpack download/confirmation prompt. | +| `confirm_download` | Click the download button to start the sync. | | `wait_download` | Wait for the marker file in the synced modpack. | | `verify_files` | Verify all configured `serverFiles.files` exist on the client. | -| `click_restart` | Click restart/quit on the restart screen if shown. | +| `verify_mods` | Verify all configured `serverFiles.expectedMods` exist on the client. | +| `confirm_restart` | Click restart/quit on the restart screen if shown. | | `wait_join` | Verify the client reaches the in-game state. | | `quit` | Stop the client through the bridge. | @@ -178,6 +180,6 @@ properties. Commands and responses are JSON files under: /automodpack/autotest/ ``` -The bridge is intentionally small and generic. It exposes HMC-specifics-style -operations for `gui`, `click`, `text`, `menu`, `close`, `connect`, `disconnect`, -`render`, and `quit`; the runner builds scenario behavior from those primitives. +The bridge is intentionally small and generic. It exposes operations for +`ping`, `gui`, `click`, `text`, `connect`, `disconnect`, and `quit`; the runner +builds scenario behavior from those primitives. diff --git a/autotester/automodpack_autotester/bridge.py b/autotester/automodpack_autotester/bridge.py index 66def5256..fef312bb5 100644 --- a/autotester/automodpack_autotester/bridge.py +++ b/autotester/automodpack_autotester/bridge.py @@ -37,18 +37,9 @@ def request(self, op: str, timeout: float = 30, **payload) -> dict: def gui(self, timeout: float = 30) -> dict: return self.request("gui", timeout=timeout) - def buttons(self, timeout: float = 30) -> list[dict]: - return list(self.gui(timeout=timeout).get("buttons", [])) - - def text_fields(self, timeout: float = 30) -> list[dict]: - return list(self.gui(timeout=timeout).get("textFields", [])) - def click(self, element_id: int, timeout: float = 30, **payload) -> dict: return self.request("click", timeout=timeout, id=element_id, **payload) - def click_point(self, x: int, y: int, button: int = 0, timeout: float = 30) -> dict: - return self.request("click", timeout=timeout, x=x, y=y, button=button) - def text(self, element_id: int, value: str, timeout: float = 30) -> dict: return self.request("text", timeout=timeout, id=element_id, text=value) diff --git a/autotester/automodpack_autotester/cli.py b/autotester/automodpack_autotester/cli.py index c82548ad2..8c98f623d 100644 --- a/autotester/automodpack_autotester/cli.py +++ b/autotester/automodpack_autotester/cli.py @@ -177,7 +177,8 @@ def main(argv: list[str] | None = None) -> int: ) if interrupted: os._exit(1) - return 0 if ok else 1 + + return 0 if ok else 1 except KeyboardInterrupt: os._exit(1) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index 133d5fa11..f24dd2895 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -570,8 +570,8 @@ def _phase_skip_fingerprint(ctx): raise RuntimeError("Skip button did not activate") -@_reg("wait_danger") -def _phase_wait_danger(ctx): +@_reg("wait_download_prompt") +def _phase_wait_download_prompt(ctx): bridge = ctx["bridge"] _await( lambda: ( @@ -582,8 +582,8 @@ def _phase_wait_danger(ctx): ) -@_reg("click_confirm") -def _phase_click_confirm(ctx): +@_reg("confirm_download") +def _phase_confirm_download(ctx): bridge = ctx["bridge"] dl = time.monotonic() + 5 while time.monotonic() < dl: @@ -649,8 +649,8 @@ def _phase_verify_mods(ctx): raise TimeoutError(f"Mods missing after sync: {', '.join(missing)}") -@_reg("click_restart") -def _phase_click_restart(ctx): +@_reg("confirm_restart") +def _phase_confirm_restart(ctx): bridge = ctx["bridge"] dl = time.monotonic() + 20 while time.monotonic() < dl: diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml index 348b89c4c..95142218a 100644 --- a/autotester/scenarios/download-only.yaml +++ b/autotester/scenarios/download-only.yaml @@ -11,8 +11,8 @@ flow: - connect - wait_fingerprint - accept_fingerprint - - wait_danger - - click_confirm + - wait_download_prompt + - confirm_download - wait_download - verify_files - quit diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index b2ed55e02..456282ad8 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -12,11 +12,11 @@ flow: - connect - wait_fingerprint - accept_fingerprint - - wait_danger - - click_confirm + - wait_download_prompt + - confirm_download - wait_download - verify_files - - click_restart + - confirm_restart - quit - launch_client - wait_bridge diff --git a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java index 6d66f75c6..1981960f3 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java +++ b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java @@ -48,21 +48,6 @@ private record ProbeResult(InitialConnectionResult success, X509Certificate untr this.connections.addAll(connections); } - /** - * Initializes the client by establishing a single "probe" connection to validate/recover SSL trust, - * then hydrates the remaining connection pool in parallel using the validated SSL context. - */ - public DownloadClient(Jsons.ModpackAddresses modpackAddresses, byte[] secretBytes, int poolSize, Function trustedByUserCallback) throws IOException { - if (poolSize < 1) throw new IllegalArgumentException("Pool size must be greater than 0"); - - KeyStore keyStore = loadDefaultKeyStore(); - - InitialConnectionResult probe = establishProbeConnection(modpackAddresses, keyStore, trustedByUserCallback); - connections.addAll(hydratePool(probe, secretBytes, poolSize, modpackAddresses)); - - LOGGER.info("Download client initialized with {} connections to {}", connections.size(), modpackAddresses.hostAddress); - } - /** * Async factory. If the certificate needs user approval, no thread blocks: the returned future * completes when the trust callback's future completes (via UI callbacks on the render thread). @@ -161,41 +146,6 @@ private static List hydratePool(InitialConnectionResult probe, byte[ return conns; } - private InitialConnectionResult establishProbeConnection(Jsons.ModpackAddresses addresses, KeyStore keyStore, Function trustCallback) throws IOException { - AtomicReference capturedChain = new AtomicReference<>(); - SSLContext context = createSSLContext(keyStore, capturedChain::set); - - try { - PreValidationConnection conn = getPreValidationConnection(addresses, context); - return new InitialConnectionResult(conn, context); - } catch (IOException e) { - return recoverProbeConnection(e, addresses, keyStore, trustCallback, capturedChain.get()); - } - } - - private InitialConnectionResult recoverProbeConnection(IOException originalError, Jsons.ModpackAddresses addresses, KeyStore keyStore, Function trustCallback, X509Certificate[] chain) throws IOException { - if (chain == null || chain.length == 0 || trustCallback == null) { - throw originalError; - } - - boolean isTrusted = trustCallback.apply(chain[0]); - if (!isTrusted) { - throw new IOException("User rejected the certificate.", originalError); - } - - try { - keyStore.setCertificateEntry(addresses.hostAddress.getHostString(), chain[0]); - - SSLContext trustedContext = createSSLContext(keyStore, null); - - PreValidationConnection retryConn = getPreValidationConnection(addresses, trustedContext); - return new InitialConnectionResult(retryConn, trustedContext); - - } catch (KeyStoreException kse) { - throw new IOException("Failed to update KeyStore with trusted certificate", kse); - } - } - private static KeyStore loadDefaultKeyStore() { try { KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java index 890c5dca7..bb0531303 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java @@ -5,21 +5,20 @@ import pl.skidam.automodpack_core.config.ConfigTools; import pl.skidam.automodpack_core.config.ConfigUtils; import pl.skidam.automodpack_core.config.Jsons; -import pl.skidam.automodpack_core.loader.LoaderManagerService; import pl.skidam.automodpack_core.utils.*; import pl.skidam.automodpack_loader_core.client.ModpackUpdater; import pl.skidam.automodpack_loader_core.client.ModpackUtils; import pl.skidam.automodpack_loader_core.loader.LoaderManager; +import pl.skidam.automodpack_core.loader.LoaderManagerService; import pl.skidam.automodpack_loader_core.mods.ModpackLoader; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; +import java.nio.file.*; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; -import java.util.HashMap; -import java.util.Set; +import java.util.*; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -27,231 +26,231 @@ public class Preload { - public Preload() { - try { - long start = System.currentTimeMillis(); - LOGGER.info("Prelaunching AutoModpack..."); - initializeConstants(); - loadConfigs(); - updateAll(); - LOGGER.info("AutoModpack prelaunched! took " + (System.currentTimeMillis() - start) + "ms"); - } catch (Exception e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - - private void updateAll() { - var optionalSelectedModpackDir = ModpackContentTools.getModpackDir(clientConfig.selectedModpack); - - if (LOADER_MANAGER.getEnvironmentType() == LoaderManagerService.EnvironmentType.SERVER || optionalSelectedModpackDir.isEmpty()) { - SelfUpdater.update(); - return; - } - - selectedModpackDir = optionalSelectedModpackDir.get(); - InetSocketAddress selectedModpackAddress = null; - InetSocketAddress selectedServerAddress = null; - boolean requiresMagic = true; // Default to true - if (!clientConfig.selectedModpack.isBlank() && clientConfig.installedModpacks.containsKey(clientConfig.selectedModpack)) { - var entry = clientConfig.installedModpacks.get(clientConfig.selectedModpack); - selectedModpackAddress = entry.hostAddress; - selectedServerAddress = entry.serverAddress; - requiresMagic = entry.requiresMagic; - } - - // Only selfupdate if no modpack is selected - if (selectedModpackAddress == null) { - SelfUpdater.update(); - LegacyClientCacheUtils.deleteDummyFiles(); - } else { - Secrets.Secret secret = SecretsStore.getClientSecret(clientConfig.selectedModpack); - - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(selectedModpackAddress, selectedServerAddress, requiresMagic); - var optionalLatestModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, false); - var latestModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); - - // Use the latest modpack content if available - if (optionalLatestModpackContent.isPresent()) { - latestModpackContent = optionalLatestModpackContent.get(); - - // Update AutoModpack to server version only if we can get newest modpack content - if (SelfUpdater.update(latestModpackContent)) { - return; - } - } - - // Delete dummy files - LegacyClientCacheUtils.deleteDummyFiles(); - - var modpackUpdaterInstance = new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir); - - if (clientConfig.updateSelectedModpackOnLaunch) { // Check updates and load the modpack - modpackUpdaterInstance.processModpackUpdate(null); - } else { // Otherwise just load the modpack - try { - modpackUpdaterInstance.CheckAndLoadModpack(); - } catch (Exception e) { - LOGGER.error("Failed to check and load modpack, trying to update it", e); - } - } - } - } - - - private void initializeConstants() { - // Initialize global variables - preload = true; - PRELOAD_TIME = System.currentTimeMillis(); - LOADER_MANAGER = new LoaderManager(); - MODPACK_LOADER = new ModpackLoader(); - MC_VERSION = LOADER_MANAGER.getModVersion("minecraft"); - LOADER_VERSION = LOADER_MANAGER.getLoaderVersion(); - LOADER = LOADER_MANAGER.getPlatformType().toString().toLowerCase(); - THIS_MOD_JAR = JarUtils.getJarPath(this.getClass()); - AM_VERSION = FileInspection.getModVersion(THIS_MOD_JAR); - MODS_DIR = THIS_MOD_JAR.getParent(); - - // Get "overrides-automodpack-client.json" zipfile from the AUTOMODPACK_JAR - try (ZipInputStream zis = new ZipInputStream(new LockFreeInputStream(THIS_MOD_JAR))) { - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (entry.getName().equals(clientConfigFileOverrideResource)) { - clientConfigOverride = new String(zis.readAllBytes(), StandardCharsets.UTF_8); - break; - } - } - } catch (IOException e) { - LOGGER.error("Failed to read overrides from jar", e); - } - } - - private void loadConfigs() { - long startTime = System.currentTimeMillis(); - - // load client config - if (clientConfigOverride == null) { - var clientConfigVersion = ConfigTools.softLoad(clientConfigFile, Jsons.VersionConfigField.class); - if (clientConfigVersion != null) { - if (clientConfigVersion.DO_NOT_CHANGE_IT == 1) { - // Update the configs schemes to not crash the game if loaded with old config! - var clientConfigV1 = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV1.class); - if (clientConfigV1 != null) { // update to V2 - just delete the installedModpacks - clientConfigVersion.DO_NOT_CHANGE_IT = 2; - clientConfigV1.DO_NOT_CHANGE_IT = 2; - clientConfigV1.installedModpacks = null; - } - - ConfigTools.save(clientConfigFile, clientConfigV1); - LOGGER.info("Updated client config version to {}", clientConfigVersion.DO_NOT_CHANGE_IT); - } - } - - clientConfig = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV2.class); - } else { - // TODO: when connecting to the new server which provides modpack different modpack, ask the user if they want, stop using overrides - LOGGER.warn("You are using unofficial {} mod", MOD_ID); - LOGGER.warn("Using client config overrides! Editing the {} file will have no effect", clientConfigFile); - LOGGER.warn("Remove the {} file from inside the jar or remove and download fresh {} mod jar from modrinth/curseforge", clientConfigFileOverrideResource, MOD_ID); - clientConfig = ConfigTools.load(clientConfigOverride, Jsons.ClientConfigFieldsV2.class); - } - - var serverConfigVersion = ConfigTools.softLoad(serverConfigFile, Jsons.VersionConfigField.class); - if (serverConfigVersion != null) { - if (serverConfigVersion.DO_NOT_CHANGE_IT == 1) { - // Update the configs schemes to make this update not as breaking as it could be - var serverConfigV1 = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV1.class); - var serverConfigV2 = ConfigTools.softLoad(serverConfigFile, Jsons.ServerConfigFieldsV2.class); - if (serverConfigV1 != null && serverConfigV2 != null) { - serverConfigVersion.DO_NOT_CHANGE_IT = 2; - serverConfigV2.DO_NOT_CHANGE_IT = 2; - - if (serverConfigV1.hostIp.isBlank()) { - serverConfigV2.addressToSend = ""; - } else { - serverConfigV2.addressToSend = AddressHelpers.parse(serverConfigV1.hostIp).getHostString(); - } - - if (serverConfigV1.hostModpackOnMinecraftPort) { - serverConfigV2.bindPort = -1; - serverConfigV2.portToSend = -1; - } else { - serverConfigV2.bindPort = serverConfigV1.hostPort; - serverConfigV2.portToSend = serverConfigV1.hostPort; - } - } - - ConfigTools.save(serverConfigFile, serverConfigV2); - LOGGER.info("Updated server config version to {}", serverConfigVersion.DO_NOT_CHANGE_IT); - } - } - - // load server config - serverConfig = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV2.class); - - if (serverConfig != null) { - // Add current loader to the list - if (serverConfig.acceptedLoaders == null) { - serverConfig.acceptedLoaders = Set.of(LOADER); - } else { - serverConfig.acceptedLoaders.add(LOADER); - } - - // Check modpack name and fix it if needed, because it will be used for naming a folder on client - if (!serverConfig.modpackName.isEmpty() && FileInspection.isInValidFileName(serverConfig.modpackName)) { - serverConfig.modpackName = FileInspection.fixFileName(serverConfig.modpackName); - LOGGER.info("Changed modpack name to {}", serverConfig.modpackName); - } - - ConfigUtils.normalizeServerConfig(serverConfig); - - // Save changes - ConfigTools.save(serverConfigFile, serverConfig); - } - - if (clientConfig != null) { - // Very important to have this map initialized - if (clientConfig.installedModpacks == null) { - clientConfig.installedModpacks = new HashMap<>(); - } - - if (clientConfig.selectedModpack == null) { - clientConfig.selectedModpack = ""; - } - - // Save changes - ConfigTools.save(clientConfigFile, clientConfig); - } - - knownHosts = ConfigTools.load(knownHostsFile, Jsons.KnownHostsFields.class); - if (knownHosts != null) { - if (knownHosts.hosts == null) { - knownHosts.hosts = new HashMap<>(); - } - } - - try { - Files.createDirectories(privateDir); - String os = System.getProperty("os.name").toLowerCase(); - try { - if (os.contains("win")) { - Files.setAttribute(privateDir, "dos:hidden", true); - } else if (os.contains("nix") || os.contains("nux") || os.contains("aix") || os.contains("mac")) { - Set perms = PosixFilePermissions.fromString("rwx------"); // Corresponds to 0700 - Files.setPosixFilePermissions(privateDir, perms); - } - } catch (UnsupportedOperationException | IOException e) { - LOGGER.debug("Failed to set private directory attributes for os: {}", os); - } - } catch (IOException e) { - LOGGER.error("Failed to create private directory", e); - } - - - if (serverConfig == null || clientConfig == null) { - throw new RuntimeException("Failed to load config!"); - } - - LOGGER.info("Loaded config! took {}ms", System.currentTimeMillis() - startTime); - } -} \ No newline at end of file + public Preload() { + try { + long start = System.currentTimeMillis(); + LOGGER.info("Prelaunching AutoModpack..."); + initializeConstants(); + loadConfigs(); + updateAll(); + LOGGER.info("AutoModpack prelaunched! took " + (System.currentTimeMillis() - start) + "ms"); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private void updateAll() { + var optionalSelectedModpackDir = ModpackContentTools.getModpackDir(clientConfig.selectedModpack); + + if (LOADER_MANAGER.getEnvironmentType() == LoaderManagerService.EnvironmentType.SERVER || optionalSelectedModpackDir.isEmpty()) { + SelfUpdater.update(); + return; + } + + selectedModpackDir = optionalSelectedModpackDir.get(); + InetSocketAddress selectedModpackAddress = null; + InetSocketAddress selectedServerAddress = null; + boolean requiresMagic = true; // Default to true + if (!clientConfig.selectedModpack.isBlank() && clientConfig.installedModpacks.containsKey(clientConfig.selectedModpack)) { + var entry = clientConfig.installedModpacks.get(clientConfig.selectedModpack); + selectedModpackAddress = entry.hostAddress; + selectedServerAddress = entry.serverAddress; + requiresMagic = entry.requiresMagic; + } + + // Only selfupdate if no modpack is selected + if (selectedModpackAddress == null) { + SelfUpdater.update(); + LegacyClientCacheUtils.deleteDummyFiles(); + } else { + Secrets.Secret secret = SecretsStore.getClientSecret(clientConfig.selectedModpack); + + Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(selectedModpackAddress, selectedServerAddress, requiresMagic); + var optionalLatestModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, false); + var latestModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); + + // Use the latest modpack content if available + if (optionalLatestModpackContent.isPresent()) { + latestModpackContent = optionalLatestModpackContent.get(); + + // Update AutoModpack to server version only if we can get newest modpack content + if (SelfUpdater.update(latestModpackContent)) { + return; + } + } + + // Delete dummy files + LegacyClientCacheUtils.deleteDummyFiles(); + + var modpackUpdaterInstance = new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir); + + if (clientConfig.updateSelectedModpackOnLaunch) { // Check updates and load the modpack + modpackUpdaterInstance.processModpackUpdate(null); + } else { // Otherwise just load the already-installed modpack + try { + modpackUpdaterInstance.checkAndLoadModpack(); + } catch (Exception e) { + LOGGER.error("Failed to check and load modpack", e); + } + } + } + } + + + private void initializeConstants() { + // Initialize global variables + preload = true; + PRELOAD_TIME = System.currentTimeMillis(); + LOADER_MANAGER = new LoaderManager(); + MODPACK_LOADER = new ModpackLoader(); + MC_VERSION = LOADER_MANAGER.getModVersion("minecraft"); + LOADER_VERSION = LOADER_MANAGER.getLoaderVersion(); + LOADER = LOADER_MANAGER.getPlatformType().toString().toLowerCase(); + THIS_MOD_JAR = JarUtils.getJarPath(this.getClass()); + AM_VERSION = FileInspection.getModVersion(THIS_MOD_JAR); + MODS_DIR = THIS_MOD_JAR.getParent(); + + // Get "overrides-automodpack-client.json" zipfile from the AUTOMODPACK_JAR + try (ZipInputStream zis = new ZipInputStream(new LockFreeInputStream(THIS_MOD_JAR))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals(clientConfigFileOverrideResource)) { + clientConfigOverride = new String(zis.readAllBytes(), StandardCharsets.UTF_8); + break; + } + } + } catch (IOException e) { + LOGGER.error("Failed to read overrides from jar", e); + } + } + + private void loadConfigs() { + long startTime = System.currentTimeMillis(); + + // load client config + if (clientConfigOverride == null) { + var clientConfigVersion = ConfigTools.softLoad(clientConfigFile, Jsons.VersionConfigField.class); + if (clientConfigVersion != null) { + if (clientConfigVersion.DO_NOT_CHANGE_IT == 1) { + // Update the configs schemes to not crash the game if loaded with old config! + var clientConfigV1 = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV1.class); + if (clientConfigV1 != null) { // update to V2 - just delete the installedModpacks + clientConfigVersion.DO_NOT_CHANGE_IT = 2; + clientConfigV1.DO_NOT_CHANGE_IT = 2; + clientConfigV1.installedModpacks = null; + } + + ConfigTools.save(clientConfigFile, clientConfigV1); + LOGGER.info("Updated client config version to {}", clientConfigVersion.DO_NOT_CHANGE_IT); + } + } + + clientConfig = ConfigTools.load(clientConfigFile, Jsons.ClientConfigFieldsV2.class); + } else { + // TODO: when connecting to the new server which provides modpack different modpack, ask the user if they want, stop using overrides + LOGGER.warn("You are using unofficial {} mod", MOD_ID); + LOGGER.warn("Using client config overrides! Editing the {} file will have no effect", clientConfigFile); + LOGGER.warn("Remove the {} file from inside the jar or remove and download fresh {} mod jar from modrinth/curseforge", clientConfigFileOverrideResource, MOD_ID); + clientConfig = ConfigTools.load(clientConfigOverride, Jsons.ClientConfigFieldsV2.class); + } + + var serverConfigVersion = ConfigTools.softLoad(serverConfigFile, Jsons.VersionConfigField.class); + if (serverConfigVersion != null) { + if (serverConfigVersion.DO_NOT_CHANGE_IT == 1) { + // Update the configs schemes to make this update not as breaking as it could be + var serverConfigV1 = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV1.class); + var serverConfigV2 = ConfigTools.softLoad(serverConfigFile, Jsons.ServerConfigFieldsV2.class); + if (serverConfigV1 != null && serverConfigV2 != null) { + serverConfigVersion.DO_NOT_CHANGE_IT = 2; + serverConfigV2.DO_NOT_CHANGE_IT = 2; + + if (serverConfigV1.hostIp.isBlank()) { + serverConfigV2.addressToSend = ""; + } else { + serverConfigV2.addressToSend = AddressHelpers.parse(serverConfigV1.hostIp).getHostString(); + } + + if (serverConfigV1.hostModpackOnMinecraftPort) { + serverConfigV2.bindPort = -1; + serverConfigV2.portToSend = -1; + } else { + serverConfigV2.bindPort = serverConfigV1.hostPort; + serverConfigV2.portToSend = serverConfigV1.hostPort; + } + } + + ConfigTools.save(serverConfigFile, serverConfigV2); + LOGGER.info("Updated server config version to {}", serverConfigVersion.DO_NOT_CHANGE_IT); + } + } + + // load server config + serverConfig = ConfigTools.load(serverConfigFile, Jsons.ServerConfigFieldsV2.class); + + if (serverConfig != null) { + // Add current loader to the list + if (serverConfig.acceptedLoaders == null) { + serverConfig.acceptedLoaders = Set.of(LOADER); + } else { + serverConfig.acceptedLoaders.add(LOADER); + } + + // Check modpack name and fix it if needed, because it will be used for naming a folder on client + if (!serverConfig.modpackName.isEmpty() && FileInspection.isInValidFileName(serverConfig.modpackName)) { + serverConfig.modpackName = FileInspection.fixFileName(serverConfig.modpackName); + LOGGER.info("Changed modpack name to {}", serverConfig.modpackName); + } + + ConfigUtils.normalizeServerConfig(serverConfig); + + // Save changes + ConfigTools.save(serverConfigFile, serverConfig); + } + + if (clientConfig != null) { + // Very important to have this map initialized + if (clientConfig.installedModpacks == null) { + clientConfig.installedModpacks = new HashMap<>(); + } + + if (clientConfig.selectedModpack == null) { + clientConfig.selectedModpack = ""; + } + + // Save changes + ConfigTools.save(clientConfigFile, clientConfig); + } + + knownHosts = ConfigTools.load(knownHostsFile, Jsons.KnownHostsFields.class); + if (knownHosts != null) { + if (knownHosts.hosts == null) { + knownHosts.hosts = new HashMap<>(); + } + } + + try { + Files.createDirectories(privateDir); + String os = System.getProperty("os.name").toLowerCase(); + try { + if (os.contains("win")) { + Files.setAttribute(privateDir, "dos:hidden", true); + } else if (os.contains("nix") || os.contains("nux") || os.contains("aix") || os.contains("mac")) { + Set perms = PosixFilePermissions.fromString("rwx------"); // Corresponds to 0700 + Files.setPosixFilePermissions(privateDir, perms); + } + } catch (UnsupportedOperationException | IOException e) { + LOGGER.debug("Failed to set private directory attributes for os: {}", os); + } + } catch (IOException e) { + LOGGER.error("Failed to create private directory", e); + } + + + if (serverConfig == null || clientConfig == null) { + throw new RuntimeException("Failed to load config!"); + } + + LOGGER.info("Loaded config! took {}ms", System.currentTimeMillis() - startTime); + } +} diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java index b7609324b..d53f3207e 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java @@ -72,7 +72,7 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { // Handle the case where serverModpackContent is null if (serverModpackContent == null) { try (var cache = FileMetadataCache.open(hashCacheDBFile)) { - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } return; } @@ -109,7 +109,7 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { } else { Files.writeString(modpackContentFile, serverModpackContentJson); try (var cache = FileMetadataCache.open(hashCacheDBFile)) { - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } } } @@ -118,13 +118,13 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { } } - public void CheckAndLoadModpack() throws Exception { + public void checkAndLoadModpack() throws Exception { try (var cache = FileMetadataCache.open(hashCacheDBFile)) { - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } } - private void CheckAndLoadModpack(FileMetadataCache cache) throws Exception { + private void checkAndLoadModpack(FileMetadataCache cache) throws Exception { if (!Files.exists(modpackDir)) return; @@ -251,7 +251,7 @@ public void startUpdate(Set files LOGGER.error("Update failed successfully! Try again! Took: {}ms", System.currentTimeMillis() - start); } else if (preload) { LOGGER.info("Update completed! Took: {}ms", System.currentTimeMillis() - start); - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } else { boolean requiredRestart = applyModpack(cache); LOGGER.info("Update completed! Required restart: {} Took: {}ms", requiredRestart, System.currentTimeMillis() - start); diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index a2a145353..b9ec11f17 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -844,11 +844,11 @@ private static CompletableFuture askUserAboutCertificateAsync(InetSocke LOGGER.info("Asking user for {}", address.getHostString()); return CompletableFuture.supplyAsync(() -> { - var parent = new ScreenManager().getScreen().orElse(null); - if (parent == null) { - LOGGER.warn("No screen available, cannot ask user"); - return false; - } + var parent = new ScreenManager().getScreen().orElse(null); + if (parent == null) { + LOGGER.warn("No screen available, cannot ask user"); + return false; + } CompletableFuture future = new CompletableFuture<>(); Runnable trustAction = () -> { diff --git a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java index 1c5beb989..6c902b608 100644 --- a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java +++ b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java @@ -17,7 +17,7 @@ public class ScreenImpl implements ScreenService { private static void executeOnClient(Runnable task) { - Minecraft.getInstance().execute(task); + Minecraft.getInstance().execute(task); } @Override diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java index 8deeaef6a..b4c356158 100644 --- a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -78,17 +78,14 @@ public static void startIfEnabled() { t.setDaemon(true); t.start(); + // Fallback in case the reload-finished mixin fires before the title screen is shown. Thread waiter = new Thread(() -> { - while (true) { + while (!CLIENT_READY.get()) { try { Thread.sleep(100); - Minecraft mc = Minecraft.getInstance(); - if (mc.screen instanceof TitleScreen && hasReloadFinished()) { - LOGGER.info("AutoModpack: Client is ready, TitleScreen detected"); + if (Minecraft.getInstance().screen instanceof TitleScreen && hasReloadFinished()) { onClientReady(); return; - } else { - LOGGER.info("AutoModpack: Waiting for TitleScreen, current screen: {}", mc.screen == null ? "null" : mc.screen.getClass().getName()); } } catch (Exception ignored) { } @@ -104,6 +101,7 @@ public static void onClientReady() { if (dir == null) return; try { writeFile(dir.resolve("bridge-state.json"), "{\"status\":\"ready\"}"); + LOGGER.info("AutoModpack autotest: client ready, wrote bridge-state.json"); } catch (IOException e) { LOGGER.error("Cannot write client-ready state", e); } @@ -159,12 +157,9 @@ private static String exec(JsonObject req) throws Exception { case "gui" -> onMain(() -> gui().toString()); case "click" -> onMain(() -> click(req)); case "text" -> onMain(() -> text(req)); - case "menu" -> onMain(AutoTestBridge::menu); - case "close" -> onMain(AutoTestBridge::close); case "connect" -> onMain(() -> connect(req)); case "disconnect" -> onMain(AutoTestBridge::disconnect); case "quit" -> onMain(AutoTestBridge::quit); - case "render" -> render(req); default -> err("unknown operation: " + optString(req, "op")); }; } @@ -175,10 +170,11 @@ private static JsonObject gui() { JsonObject o = base(); o.addProperty("screenClass", s == null ? null : s.getClass().getName()); o.addProperty("title", s == null ? null : s.getTitle().getString()); - o.add("buttons", elementsJson(elements(s).buttons())); - o.add("textFields", elementsJson(elements(s).textFields())); - o.add("other", elementsJson(elements(s).other())); - o.add("elements", elementsJson(elements(s).all())); + GuiElements elements = elements(s); + o.add("buttons", elementsJson(elements.buttons())); + o.add("textFields", elementsJson(elements.textFields())); + o.add("other", elementsJson(elements.other())); + o.add("elements", elementsJson(elements.all())); return o; } @@ -230,21 +226,6 @@ private static String text(JsonObject req) { return ok(); } - private static String menu() { - Minecraft c = Minecraft.getInstance(); - if (c.player == null) return err("not in game"); - if (c.screen != null) c.screen.onClose(); - c.pauseGame(false); - return ok(); - } - - private static String close() { - Minecraft c = Minecraft.getInstance(); - if (c.player == null) return err("not in game"); - if (c.screen != null) c.screen.onClose(); - return ok(); - } - private static String connect(JsonObject req) { Minecraft c = Minecraft.getInstance(); String host = optString(req, "host"); @@ -288,28 +269,6 @@ private static String quit() { return ok(); } - private static String render(JsonObject req) throws InterruptedException { - int millis = Math.max(1, optInt(req, "time", 1000)); - boolean includeDuplicates = has(req, "includeDuplicates") && req.get("includeDuplicates").getAsBoolean(); - RenderedTextCollector.Session session = RenderedTextCollector.start(); - try { - Thread.sleep(millis); - JsonObject o = base(); - JsonArray a = new JsonArray(); - for (RenderedTextCollector.Entry entry : session.entries(includeDuplicates)) { - JsonObject e = new JsonObject(); - e.addProperty("text", entry.text()); - e.addProperty("x", entry.x()); - e.addProperty("y", entry.y()); - a.add(e); - } - o.add("strings", a); - return o.toString(); - } finally { - session.close(); - } - } - private static GuiElements elements(Screen screen) { if (screen == null) return new GuiElements(List.of()); diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java b/src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java deleted file mode 100644 index 32020f2cd..000000000 --- a/src/main/java/pl/skidam/automodpack/client/autotest/FormattedText.java +++ /dev/null @@ -1,16 +0,0 @@ -package pl.skidam.automodpack.client.autotest; - -import net.minecraft.util.FormattedCharSequence; - -public final class FormattedText { - private FormattedText() {} - - public static String toString(FormattedCharSequence sequence) { - StringBuilder builder = new StringBuilder(); - sequence.accept((index, style, codePoint) -> { - builder.append((char) codePoint); - return true; - }); - return builder.toString(); - } -} diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java b/src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java deleted file mode 100644 index b17533df9..000000000 --- a/src/main/java/pl/skidam/automodpack/client/autotest/RenderedTextCollector.java +++ /dev/null @@ -1,52 +0,0 @@ -package pl.skidam.automodpack.client.autotest; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; - -public final class RenderedTextCollector { - private static final CopyOnWriteArrayList SESSIONS = new CopyOnWriteArrayList<>(); - - private RenderedTextCollector() {} - - public static Session start() { - Session session = new Session(); - SESSIONS.add(session); - return session; - } - - public static void record(String text, float x, float y) { - if (text == null || text.isBlank() || SESSIONS.isEmpty()) return; - Entry entry = new Entry(text, x, y); - for (Session session : SESSIONS) { - session.record(entry); - } - } - - public record Entry(String text, float x, float y) {} - - public static final class Session implements AutoCloseable { - private final CopyOnWriteArrayList entries = new CopyOnWriteArrayList<>(); - - private void record(Entry entry) { - entries.add(entry); - } - - public List entries(boolean includeDuplicates) { - if (includeDuplicates) return new ArrayList<>(entries); - - Map unique = new LinkedHashMap<>(); - for (Entry entry : entries) { - unique.putIfAbsent(entry.text() + "\u0000" + entry.x() + "\u0000" + entry.y(), entry); - } - return new ArrayList<>(unique.values()); - } - - @Override - public void close() { - SESSIONS.remove(this); - } - } -} diff --git a/src/main/java/pl/skidam/automodpack/init/FabricInit.java b/src/main/java/pl/skidam/automodpack/init/FabricInit.java index dde2ec349..21861f227 100644 --- a/src/main/java/pl/skidam/automodpack/init/FabricInit.java +++ b/src/main/java/pl/skidam/automodpack/init/FabricInit.java @@ -2,7 +2,6 @@ /*? if fabric {*/ import pl.skidam.automodpack.client.ScreenImpl; -import pl.skidam.automodpack.client.autotest.AutoTestBridge; import pl.skidam.automodpack.client.audio.AudioManager; import pl.skidam.automodpack.modpack.Commands; import pl.skidam.automodpack.networking.ModPackets; diff --git a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java index f965f6603..df89b0ebe 100644 --- a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java @@ -2,7 +2,6 @@ /*? if forge {*/ /*import pl.skidam.automodpack.client.ScreenImpl; -import pl.skidam.automodpack.client.autotest.AutoTestBridge; import pl.skidam.automodpack.client.audio.AudioManager; import pl.skidam.automodpack.modpack.Commands; import pl.skidam.automodpack.networking.ModPackets; diff --git a/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java b/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java index 15c2c3bee..5a27ccbcd 100644 --- a/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/NeoForgeInit.java @@ -5,7 +5,6 @@ import net.neoforged.fml.common.EventBusSubscriber; /^?}^/ import pl.skidam.automodpack.client.ScreenImpl; -import pl.skidam.automodpack.client.autotest.AutoTestBridge; import pl.skidam.automodpack.client.audio.AudioManager; import pl.skidam.automodpack.modpack.Commands; import pl.skidam.automodpack.networking.ModPackets; diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java deleted file mode 100644 index 73ba0022d..000000000 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/FontRenderMixin.java +++ /dev/null @@ -1,107 +0,0 @@ -package pl.skidam.automodpack.mixin.dev; - -import net.minecraft.client.gui.Font; -import net.minecraft.client.renderer.MultiBufferSource; -import net.minecraft.util.FormattedCharSequence; -/*? if >=26.1 {*/ -import net.minecraft.network.chat.Component; -import org.joml.Matrix4fc; -/*?} else if >=1.19.3 {*/ -/*import org.joml.Matrix4f; -*//*?} else {*/ -/*import com.mojang.math.Matrix4f; -*//*?}*/ -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import pl.skidam.automodpack.client.autotest.FormattedText; -import pl.skidam.automodpack.client.autotest.RenderedTextCollector; - -@Mixin(Font.class) -public abstract class FontRenderMixin { - /*? if >=26.1 {*/ - @Inject( - method = "drawInBatch(Ljava/lang/String;FFIZLorg/joml/Matrix4fc;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)V", - at = @At("HEAD"), - require = 0) - private void automodpack$drawString(String text, float x, float y, int color, boolean shadow, Matrix4fc pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfo ci) { - RenderedTextCollector.record(text, x, y); - } - - @Inject( - method = "drawInBatch(Lnet/minecraft/network/chat/Component;FFIZLorg/joml/Matrix4fc;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)V", - at = @At("HEAD"), - require = 0) - private void automodpack$drawComponent(Component text, float x, float y, int color, boolean shadow, Matrix4fc pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfo ci) { - RenderedTextCollector.record(FormattedText.toString(text.getVisualOrderText()), x, y); - } - - @Inject( - method = "drawInBatch(Lnet/minecraft/util/FormattedCharSequence;FFIZLorg/joml/Matrix4fc;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)V", - at = @At("HEAD"), - require = 0) - private void automodpack$drawSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, Matrix4fc pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfo ci) { - RenderedTextCollector.record(FormattedText.toString(text), x, y); - } - - @Inject(method = "drawInBatch8xOutline", at = @At("HEAD"), require = 0) - private void automodpack$drawOutline(FormattedCharSequence text, float x, float y, int color, int outlineColor, Matrix4fc pose, MultiBufferSource source, int light, CallbackInfo ci) { - RenderedTextCollector.record(FormattedText.toString(text), x, y); - } - - @Inject( - method = "prepareText(Ljava/lang/String;FFIZI)Lnet/minecraft/client/gui/Font$PreparedText;", - at = @At("HEAD"), - require = 0) - private void automodpack$prepareString(String text, float x, float y, int color, boolean shadow, int background, CallbackInfoReturnable cir) { - RenderedTextCollector.record(text, x, y); - } - - @Inject( - method = "prepareText(Lnet/minecraft/util/FormattedCharSequence;FFIZZI)Lnet/minecraft/client/gui/Font$PreparedText;", - at = @At("HEAD"), - require = 0) - private void automodpack$prepareSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, boolean includeEmpty, int background, CallbackInfoReturnable cir) { - RenderedTextCollector.record(FormattedText.toString(text), x, y); - } - /*?} else if >=1.19.3 {*/ - /*@Inject( - method = "renderText(Lnet/minecraft/util/FormattedCharSequence;FFIZLorg/joml/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)F", - at = @At("HEAD"), - require = 0) - private void automodpack$renderSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfoReturnable cir) { - RenderedTextCollector.record(FormattedText.toString(text), x, y); - } - - @Inject( - method = "renderText(Ljava/lang/String;FFIZLorg/joml/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;Lnet/minecraft/client/gui/Font$DisplayMode;II)F", - at = @At("HEAD"), - require = 0) - private void automodpack$renderString(String text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, Font.DisplayMode mode, int background, int light, CallbackInfoReturnable cir) { - RenderedTextCollector.record(text, x, y); - } - - @Inject(method = "drawInBatch8xOutline", at = @At("HEAD"), require = 0) - private void automodpack$drawOutline(FormattedCharSequence text, float x, float y, int color, int outlineColor, Matrix4f pose, MultiBufferSource source, int light, CallbackInfo ci) { - RenderedTextCollector.record(FormattedText.toString(text), x, y); - } - *//*?} else {*/ - /*@Inject( - method = "renderText(Lnet/minecraft/util/FormattedCharSequence;FFIZLcom/mojang/math/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;ZII)F", - at = @At("HEAD"), - require = 0) - private void automodpack$renderSequence(FormattedCharSequence text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, boolean seeThrough, int background, int light, CallbackInfoReturnable cir) { - RenderedTextCollector.record(FormattedText.toString(text), x, y); - } - - @Inject( - method = "renderText(Ljava/lang/String;FFIZLcom/mojang/math/Matrix4f;Lnet/minecraft/client/renderer/MultiBufferSource;ZII)F", - at = @At("HEAD"), - require = 0) - private void automodpack$renderString(String text, float x, float y, int color, boolean shadow, Matrix4f pose, MultiBufferSource source, boolean seeThrough, int background, int light, CallbackInfoReturnable cir) { - RenderedTextCollector.record(text, x, y); - } - *//*?}*/ -} diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java index f427dc2e2..d1479df76 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java @@ -11,8 +11,8 @@ @Mixin(Minecraft.class) public abstract class MinecraftMixin { - @Inject(method = "", at = @At("RETURN")) - private void onInit(GameConfig gameConfig, CallbackInfo ci) { - AutoTestBridge.startIfEnabled(); - } + @Inject(method = "", at = @At("RETURN")) + private void onInit(GameConfig gameConfig, CallbackInfo ci) { + AutoTestBridge.startIfEnabled(); + } } diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java index 3adf9a155..fbe1deddd 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java @@ -12,13 +12,11 @@ @Mixin(ResourceLoadStateTracker.class) public class ResourceLoadStateTrackerMixin { - @Inject(method = "finishReload", at = @At("RETURN")) - private void onFinishReload(CallbackInfo ci) { - AutoTestBridge.markReloadFinished(); - Minecraft mc = Minecraft.getInstance(); - if (mc.screen instanceof TitleScreen) { - System.out.println("AutoModpack: Client is ready, reload finished, TitleScreen detected"); - AutoTestBridge.onClientReady(); - } - } + @Inject(method = "finishReload", at = @At("RETURN")) + private void onFinishReload(CallbackInfo ci) { + AutoTestBridge.markReloadFinished(); + if (Minecraft.getInstance().screen instanceof TitleScreen) { + AutoTestBridge.onClientReady(); + } + } } diff --git a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java index 486255577..ec6a75188 100644 --- a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java +++ b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java @@ -61,7 +61,7 @@ private boolean handlePacket(int queryId, Identifier channelName, FriendlyByteBu } catch (Throwable e) { LOGGER.error("Encountered exception while handling in channel with name \"{}\"", channelName, e); sendResponse(queryId, channelName, new FriendlyByteBuf(Unpooled.buffer())); - throw e; + throw e; } return true; diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 6497f06ad..43378fad9 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -62,30 +62,30 @@ public static CompletableFuture receive(Minecraft client, Clien Path modpackDir; Jsons.ModpackAddresses modpackAddresses; try { - // Get actual address of the server client have connected to and format it - InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); - String effectiveHost; - int effectivePort; - - // If the packet specifies a non-blank address, use it or else use address from the server client have connected to. - // Important! Use getAddress().getHostAddress() instead of getHostString() - // because Minecraft creates connectedAddress instance through a constructor which attempts a reverse DNS lookup - // which resolves PTR record for the IP address and stores the resolved hostname in the hostname field. - if (packetAddress.isBlank()) { - var connectedInetAddress = connectedAddress.getAddress(); - effectiveHost = connectedInetAddress == null ? connectedAddress.getHostString() : connectedInetAddress.getHostAddress(); - } else { - effectiveHost = packetAddress; - } - - if (packetPort == -1) { - effectivePort = connectedAddress.getPort(); - } else { - effectivePort = packetPort; - } - - // Construct the final modpack address - InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); + // Get actual address of the server client have connected to and format it + InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); + String effectiveHost; + int effectivePort; + + // If the packet specifies a non-blank address, use it or else use address from the server client have connected to. + // Important! Use getAddress().getHostAddress() instead of getHostString() + // because Minecraft creates connectedAddress instance through a constructor which attempts a reverse DNS lookup + // which resolves PTR record for the IP address and stores the resolved hostname in the hostname field. + if (packetAddress.isBlank()) { + var connectedInetAddress = connectedAddress.getAddress(); + effectiveHost = connectedInetAddress == null ? connectedAddress.getHostString() : connectedInetAddress.getHostAddress(); + } else { + effectiveHost = packetAddress; + } + + if (packetPort == -1) { + effectivePort = connectedAddress.getPort(); + } else { + effectivePort = packetPort; + } + + // Construct the final modpack address + InetSocketAddress modpackAddress = AddressHelpers.format(effectiveHost, effectivePort); LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); @@ -98,14 +98,6 @@ public static CompletableFuture receive(Minecraft client, Clien return ModpackUtils.requestServerModpackContentAsync(modpackAddresses, secret, true) .thenApplyAsync(optionalServerModpackContent -> { - long t0 = System.currentTimeMillis(); - - if (optionalServerModpackContent.isEmpty()) { - if (ModpackUtils.canConnectModpackHost(modpackAddresses)) { - return buildResponse(true); - } - } - Boolean needsDisconnecting = null; if (optionalServerModpackContent.isPresent()) { @@ -134,6 +126,9 @@ public static CompletableFuture receive(Minecraft client, Clien needsDisconnecting = false; } } + } else if (ModpackUtils.canConnectModpackHost(modpackAddresses)) { + // Couldn't download the modpack content (e.g. certificate not verified) but the host is reachable + needsDisconnecting = true; } if (clientConfig.selectedModpack != null && !clientConfig.selectedModpack.isBlank()) { diff --git a/src/main/resources/automodpack-main.mixins.json b/src/main/resources/automodpack-main.mixins.json index 5b9cf9b9f..958c599aa 100644 --- a/src/main/resources/automodpack-main.mixins.json +++ b/src/main/resources/automodpack-main.mixins.json @@ -20,7 +20,6 @@ "core.ClientLoginNetworkHandlerMixin", "core.ConnectScreenMixin", "core.MusicTrackerMixin", - "dev.FontRenderMixin", "dev.MinecraftMixin", "dev.ResourceLoadStateTrackerMixin" ], From d258a20be02f8c77fbabe24ee17a11715eb2d7d5 Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 26 Jun 2026 12:54:46 +0200 Subject: [PATCH 37/44] autotester: wire up MC 26.2 on top of merged port (#496) Rebased onto main's 26.2 port and hooked 26.2 into the autotester for both Fabric and NeoForge. Build/wiring: - ModuleUtils: map 26.2 neoforge onto our fml11 module (main used fml10; this branch moved 26.x to fml11). - ScreenImpl.setScreen: add the >=26.2 gui.setScreen conditional to match getScreen (kept our non-backgroundExecutor variant). - stonecutter: active project set to 26.2-fabric. - autotester targets: add 26.2-fabric (loader 0.19.3) and 26.2-neoforge (26.2.0.7-beta), Java 25. Deps: - Gradle 9.4.1 -> 9.6.0, fabric-loom(+remap) 1.16 -> 1.17-SNAPSHOT (loom 1.17 needs Gradle 9.5+), shadow 9.4.1 -> 9.4.3, kotlin 2.3.0 -> 2.3.21. moddevgradle already latest (2.0.141). AutoTestBridge / dev mixin: read the current screen via the existing ScreenManager().getScreen() accessor and set the title screen via minecraft.setScreen, so the 26.2 gui.* moves are covered by the port's existing stonecutter replacements + ScreenImpl conditional. No new stonecutter replacements added. Builds clean for 26.2 fabric+neoforge (and 1.21.11 sanity). The 26.2 in-game autotest still fails: HeadlessMC 2.9.0's LWJGL stub leaves org.lwjgl.system.Configuration.SHARED_LIBRARY_EXTRACT_PATH null, which MC 26.2's new NativeLibrariesBootstrap reads -> NPE before any mod loads. Needs a HeadlessMC-side fix. --- autotester/targets.yaml | 2 ++ buildSrc/src/main/kotlin/ModuleUtils.kt | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../skidam/automodpack/client/ScreenImpl.java | 6 +++- .../client/autotest/AutoTestBridge.java | 29 ++++++++++--------- .../ui/FingerprintVerificationScreen.java | 2 +- .../dev/ResourceLoadStateTrackerMixin.java | 4 +-- stonecutter.gradle.kts | 10 +++---- 8 files changed, 33 insertions(+), 24 deletions(-) diff --git a/autotester/targets.yaml b/autotester/targets.yaml index 4e6df4c72..70a8d231c 100644 --- a/autotester/targets.yaml +++ b/autotester/targets.yaml @@ -3,6 +3,8 @@ defaults: fabricLoader: "0.17.3" targets: + - { id: "26.2-fabric", minecraft: "26.2", loader: "fabric", java: 25, fabricLoader: "0.19.3" } + - { id: "26.2-neoforge", minecraft: "26.2", loader: "neoforge", java: 25, neoforgeVersion: "26.2.0.7-beta" } - { id: "26.1-fabric", minecraft: "26.1", loader: "fabric", java: 25, fabricLoader: "0.18.4" } - { id: "26.1-neoforge", minecraft: "26.1", loader: "neoforge", java: 25, neoforgeVersion: "26.1.2.64-beta" } - { id: "1.21.11-fabric", minecraft: "1.21.11", loader: "fabric", java: 21, fabricLoader: "0.17.3" } diff --git a/buildSrc/src/main/kotlin/ModuleUtils.kt b/buildSrc/src/main/kotlin/ModuleUtils.kt index a0420e930..f374d29cd 100644 --- a/buildSrc/src/main/kotlin/ModuleUtils.kt +++ b/buildSrc/src/main/kotlin/ModuleUtils.kt @@ -12,7 +12,7 @@ fun getLoaderModuleName(name: String): String { name.contains("neoforge") -> when (mcVersion) { "1.21.8", "1.21.5", "1.21.4", "1.21.1" -> "neoforge-fml4" "1.21.10", "1.21.11" -> "neoforge-fml10" - "26.1" -> "neoforge-fml11" + "26.1", "26.2" -> "neoforge-fml11" else -> error("Unknown neoforge loader module for Minecraft version: $mcVersion") } name.contains("forge") -> if (mcVersion == "1.18.2") "forge-fml40" else "forge-fml47" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118f7..7e7d24f6f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java index 6c902b608..4c9189dca 100644 --- a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java +++ b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java @@ -86,7 +86,11 @@ private static Screen getScreen() { } public static void setScreen(Screen screen) { - Minecraft.getInstance().setScreen(screen); + /*? if >=26.2 {*/ + Minecraft.getInstance().gui.setScreen(screen); + /*?} else {*/ + /*Minecraft.getInstance().setScreen(screen); + *//*?}*/ } public static void download(Object downloadManager, Object header) { diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java index b4c356158..11f809a07 100644 --- a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import net.minecraft.client.Minecraft; +import pl.skidam.automodpack_loader_core.screen.ScreenManager; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; @@ -83,7 +84,7 @@ public static void startIfEnabled() { while (!CLIENT_READY.get()) { try { Thread.sleep(100); - if (Minecraft.getInstance().screen instanceof TitleScreen && hasReloadFinished()) { + if (currentScreen() instanceof TitleScreen && hasReloadFinished()) { onClientReady(); return; } @@ -164,9 +165,12 @@ private static String exec(JsonObject req) throws Exception { }; } + private static Screen currentScreen() { + return (Screen) new ScreenManager().getScreen().orElse(null); + } + private static JsonObject gui() { - Minecraft c = Minecraft.getInstance(); - Screen s = c.screen; + Screen s = currentScreen(); JsonObject o = base(); o.addProperty("screenClass", s == null ? null : s.getClass().getName()); o.addProperty("title", s == null ? null : s.getTitle().getString()); @@ -179,8 +183,7 @@ private static JsonObject gui() { } private static String click(JsonObject req) { - Minecraft c = Minecraft.getInstance(); - Screen s = c.screen; + Screen s = currentScreen(); if (s == null) return err("no screen"); int button = optInt(req, "button", 0); @@ -213,7 +216,7 @@ private static String click(JsonObject req) { } private static String text(JsonObject req) { - Screen s = Minecraft.getInstance().screen; + Screen s = currentScreen(); if (s == null) return err("no screen"); int id = optInt(req, "id", -1); @@ -248,19 +251,19 @@ private static String connect(JsonObject req) { } private static String disconnect() { - Minecraft c = Minecraft.getInstance(); - if (c.level == null) { - c.setScreen(new TitleScreen()); + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null) { + minecraft.gui.setScreen(new TitleScreen()); return ok(); } /*? if >=1.21.6 {*/ - c.level.disconnect(translatable("multiplayer.status.quitting")); - c.clearClientLevel(new GenericMessageScreen(translatable("multiplayer.disconnect.generic"))); + minecraft.level.disconnect(translatable("multiplayer.status.quitting")); + minecraft.clearClientLevel(new GenericMessageScreen(translatable("multiplayer.disconnect.generic"))); /*?} else {*/ - /*c.level.disconnect(); + /*minecraft.level.disconnect(); *//*?}*/ - c.setScreen(new TitleScreen()); + minecraft.gui.setScreen(new TitleScreen()); return ok(); } diff --git a/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java b/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java index bfd039a34..dcb6f5dc2 100644 --- a/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java +++ b/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java @@ -99,7 +99,7 @@ public void forceValidate() { this.validated = true; this.inputText = ""; if (this.minecraft != null) { - this.minecraft.setScreen(parent); + this.minecraft.gui.setScreen(parent); } validatedCallback.run(); } diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java index fbe1deddd..8196395cc 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java @@ -1,6 +1,5 @@ package pl.skidam.automodpack.mixin.dev; -import net.minecraft.client.Minecraft; import net.minecraft.client.ResourceLoadStateTracker; import net.minecraft.client.gui.screens.TitleScreen; import org.spongepowered.asm.mixin.Mixin; @@ -8,6 +7,7 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import pl.skidam.automodpack.client.autotest.AutoTestBridge; +import pl.skidam.automodpack_loader_core.screen.ScreenManager; @Mixin(ResourceLoadStateTracker.class) public class ResourceLoadStateTrackerMixin { @@ -15,7 +15,7 @@ public class ResourceLoadStateTrackerMixin { @Inject(method = "finishReload", at = @At("RETURN")) private void onFinishReload(CallbackInfo ci) { AutoTestBridge.markReloadFinished(); - if (Minecraft.getInstance().screen instanceof TitleScreen) { + if (new ScreenManager().getScreen().orElse(null) instanceof TitleScreen) { AutoTestBridge.onClientReady(); } } diff --git a/stonecutter.gradle.kts b/stonecutter.gradle.kts index 5164b069f..92324a91f 100644 --- a/stonecutter.gradle.kts +++ b/stonecutter.gradle.kts @@ -1,10 +1,10 @@ plugins { id("dev.kikugie.stonecutter") - kotlin("jvm") version "2.3.0" apply false - id("net.fabricmc.fabric-loom-remap") version "1.16-SNAPSHOT" apply false - id("net.fabricmc.fabric-loom") version "1.16-SNAPSHOT" apply false + kotlin("jvm") version "2.3.21" apply false + id("net.fabricmc.fabric-loom-remap") version "1.17-SNAPSHOT" apply false + id("net.fabricmc.fabric-loom") version "1.17-SNAPSHOT" apply false id("net.neoforged.moddev") version "2.0.141" apply false - id("com.gradleup.shadow") version "9.4.1" apply false + id("com.gradleup.shadow") version "9.4.3" apply false id("org.moddedmc.wiki.toolkit") version "0.4+" } @@ -14,7 +14,7 @@ wiki { } } -stonecutter active "26.1-fabric" /* [SC] DO NOT EDIT */ +stonecutter active "26.2-fabric" /* [SC] DO NOT EDIT */ stonecutter.parameters { val (version, loader) = current.project.split('-', limit = 2) From 2a34cb206bc157ec005bc31918c70c22c21bbeed Mon Sep 17 00:00:00 2001 From: skidam Date: Fri, 26 Jun 2026 18:06:17 +0200 Subject: [PATCH 38/44] autotester: build the HeadlessMC client from our patched fork Stock HeadlessMC can't launch MC 26.2 headlessly (its LWJGL stubs don't satisfy 26.2's new render backend). Build the client image's HeadlessMC launcher from a git repo/ref instead of downloading the prebuilt native release: - docker/client/Dockerfile: multi-stage build that clones HEADLESSMC_REPO at HEADLESSMC_REF and compiles the launcher-wrapper jar with JDK 21, installed as a `java -jar` hmc shim. - settings.yaml: headlessmc.repo/ref select the build (defaults to the patched Skidamek/headlessmc @ mc26.2-headless); point elsewhere to use another build. - cli.py: pass repo/ref through as Docker build args. - README: document the HeadlessMC build source. Verified: full sync matrix passes for all 22 targets (both loaders, MC 1.18.2 through 26.2). --- autotester/README.md | 18 ++++++++ autotester/automodpack_autotester/cli.py | 14 ++++--- autotester/docker/client/Dockerfile | 52 +++++++++++++++++------- autotester/settings.yaml | 6 ++- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/autotester/README.md b/autotester/README.md index ad295636e..268c9c101 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -76,6 +76,24 @@ Common run options: | `--out-dir PATH` | Directory for logs and `results.json`. | | `--client-image IMG` | Docker image used for HeadlessMC clients. | +### HeadlessMC build + +The client image builds its HeadlessMC launcher from the git repo and ref in +`settings.yaml`: + +```yaml +headlessmc: + repo: "https://github.com/Skidamek/headlessmc.git" + ref: "mc26.2-headless" +``` + +This default ref carries the patch required to launch **MC 26.2** headlessly +(stock HeadlessMC can't yet — its LWJGL stubs don't satisfy 26.2's new render +backend); it does not change behavior on other versions. Point `repo`/`ref` at +any other HeadlessMC build and rebuild the image +(`uv --project autotester run autotester build-images`). If they are unset, the +build falls back to upstream HeadlessMC (`headlesshq/headlessmc`). + ## Scenarios A scenario is an ordered list of registered phases plus optional topology and diff --git a/autotester/automodpack_autotester/cli.py b/autotester/automodpack_autotester/cli.py index 8c98f623d..983059f9b 100644 --- a/autotester/automodpack_autotester/cli.py +++ b/autotester/automodpack_autotester/cli.py @@ -45,7 +45,6 @@ def main(argv: list[str] | None = None) -> int: build = sub.add_parser("build-images") build.add_argument("--client-image") - build.add_argument("--headlessmc-version") run_p = sub.add_parser("run") run_p.add_argument("--target") @@ -64,17 +63,22 @@ def main(argv: list[str] | None = None) -> int: if args.command == "build-images": s = load_settings() - ver = args.headlessmc_version or str( - s.get("headlessmc", {}).get("version", "2.9.0") - ) + hmc = s.get("headlessmc", {}) img = args.client_image or str( s.get("images", {}).get("client", "automodpack-autotest-client:local") ) + # Pass through only what settings provide; the Dockerfile falls back to + # upstream HeadlessMC for anything unset. + buildargs = {} + if hmc.get("repo"): + buildargs["HEADLESSMC_REPO"] = str(hmc["repo"]) + if hmc.get("ref"): + buildargs["HEADLESSMC_REF"] = str(hmc["ref"]) docker_py.from_env().images.build( path=str(ROOT / "docker" / "client"), dockerfile=str(ROOT / "docker" / "client" / "Dockerfile"), tag=img, - buildargs={"HEADLESSMC_VERSION": ver}, + buildargs=buildargs, rm=True, ) return 0 diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile index f0f393c49..7ff1ee504 100644 --- a/autotester/docker/client/Dockerfile +++ b/autotester/docker/client/Dockerfile @@ -1,6 +1,31 @@ -FROM ubuntu:26.04 +# Git repository and ref the HeadlessMC launcher is built from. These are +# normally supplied by `autotester build-images` from settings.yaml +# (headlessmc.repo/ref). The defaults below are only a fallback for a bare +# `docker build` and point at upstream HeadlessMC; set headlessmc.repo/ref in +# settings.yaml to build a patched launcher (e.g. for MC 26.2 headless). +ARG HEADLESSMC_REPO=https://github.com/headlesshq/headlessmc.git +ARG HEADLESSMC_REF=main + +# ── Builder: compile the HeadlessMC launcher wrapper jar ────────────── +# HeadlessMC must be built with JDK 21 (its Gradle build rejects newer JDKs). +FROM eclipse-temurin:21-jdk AS hmc +ARG HEADLESSMC_REPO +ARG HEADLESSMC_REF +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +RUN git clone --depth 1 --branch "${HEADLESSMC_REF}" "${HEADLESSMC_REPO}" . \ + && ./gradlew --no-daemon headlessmc-launcher-wrapper:build -x test \ + && jar="$(find headlessmc-launcher-wrapper/build/libs -maxdepth 1 \ + -name 'headlessmc-launcher-wrapper-*.jar' \ + ! -name '*-dev.jar' ! -name '*-javadoc.jar' ! -name '*-sources.jar' \ + | head -n1)" \ + && test -n "$jar" \ + && cp "$jar" /headlessmc-launcher-wrapper.jar -ARG HEADLESSMC_VERSION=2.9.0 +# ── Runtime client image ────────────────────────────────────────────── +FROM ubuntu:26.04 ENV DEBIAN_FRONTEND=noninteractive ENV HMC_HOME=/work/hmc-cache @@ -29,19 +54,16 @@ RUN apt-get update \ openjdk-25-jre-headless \ && rm -rf /var/lib/apt/lists/* -# Detect architecture at build time and fetch the matching HMC binary -RUN HMC_ARCH="$(uname -m)" && \ - case "$HMC_ARCH" in \ - x86_64) HMC_BIN="headlessmc-launcher-linux-x64" ;; \ - aarch64) HMC_BIN="headlessmc-launcher-linux-arm64" ;; \ - *) echo "Unsupported architecture: $HMC_ARCH"; exit 1 ;; \ - esac && \ - mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache /work/HeadlessMC /work/hmc-shared-versions && \ - chmod a+rwx /work/HeadlessMC /work/hmc-shared-versions && \ - curl -fsSL \ - "https://github.com/headlesshq/headlessmc/releases/download/${HEADLESSMC_VERSION}/${HMC_BIN}" \ - -o /usr/local/bin/hmc && \ - chmod +x /usr/local/bin/hmc +RUN mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache \ + /work/HeadlessMC /work/hmc-shared-versions \ + && chmod a+rwx /work/HeadlessMC /work/hmc-shared-versions + +# Install the HeadlessMC launcher built above as the `hmc` command. +COPY --from=hmc /headlessmc-launcher-wrapper.jar \ + /opt/headlessmc/headlessmc-launcher-wrapper.jar +RUN printf '#!/usr/bin/env bash\nexec java -jar /opt/headlessmc/headlessmc-launcher-wrapper.jar "$@"\n' \ + > /usr/local/bin/hmc \ + && chmod +x /usr/local/bin/hmc COPY run-headlessmc-client /opt/automodpack/run-headlessmc-client RUN chmod +x /opt/automodpack/run-headlessmc-client diff --git a/autotester/settings.yaml b/autotester/settings.yaml index 615c9d9c5..f02c5b35d 100644 --- a/autotester/settings.yaml +++ b/autotester/settings.yaml @@ -37,7 +37,11 @@ serverTypes: neoforge: NEOFORGE headlessmc: - version: "2.9.0" + # Git repository and ref the HeadlessMC launcher is built from. The default + # builds the patch required to launch MC 26.2 headlessly; point these at a + # different repo/ref to use another HeadlessMC build. + repo: "https://github.com/Skidamek/headlessmc.git" + ref: "mc26.2-headless" timeouts: serverStartSeconds: 180 From d3095c1aaead68fab7abb1336b9450ad6f71f74a Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 27 Jun 2026 01:43:47 +0200 Subject: [PATCH 39/44] =?UTF-8?q?autotester:=20review-pass=20cleanup=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20deps/code,=20fix=20report=20aggregati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address self-review comments on the autotester branch: - neoforge: remove the forgified-fabric-api dependency, its >=1.21 guard and the FFAPI maven repo. Nothing in the neoforge source needs it — FabricInit is fabric-gated and FabricLoginMixin targets by string + @Pseudo — and it was never bundled or declared in neoforge.mods.toml. Verified 1.21.8/26.2 neoforge still compile. - mixins: drop the BlockPos no-op workaround in LoginQueryResponse/Request login mixins. Keep targeting the real packet class (reversed to its old name on <1.20.2 by the existing stonecutter replacement) and gate only the body. Verified the no-op path (1.18.2/1.19.2) and the injection path (1.21.8/26.2). - ingame-tests.yml: drop `merge-multiple: true` on the report download — it collided every target's results.json into one, so aggregation only saw a single target. - run-headlessmc-client: remove the dead commented forge/neoforge install blocks; document why only fabric needs an explicit profile install. - settings.yaml: remove the unused run.retryMax key. - autotester package: remove the empty __init__.py and switch packaging to namespace discovery. Claude-Session: https://claude.ai/code/session_01AQ1GKvoVqnwharKmXpbwSz --- .github/workflows/ingame-tests.yml | 1 - autotester/automodpack_autotester/__init__.py | 5 ----- .../docker/client/run-headlessmc-client | 20 ++++--------------- autotester/pyproject.toml | 1 + autotester/settings.yaml | 1 - build.neoforge.gradle.kts | 8 -------- .../core/LoginQueryRequestS2CPacketMixin.java | 16 +++++++-------- .../LoginQueryResponseC2SPacketMixin.java | 16 +++++++-------- 8 files changed, 19 insertions(+), 49 deletions(-) delete mode 100644 autotester/automodpack_autotester/__init__.py diff --git a/.github/workflows/ingame-tests.yml b/.github/workflows/ingame-tests.yml index 48f9da9a1..908e6ed46 100644 --- a/.github/workflows/ingame-tests.yml +++ b/.github/workflows/ingame-tests.yml @@ -78,7 +78,6 @@ jobs: - uses: actions/download-artifact@v6 with: pattern: autotester-* - merge-multiple: true - name: Aggregate results id: aggregate run: | diff --git a/autotester/automodpack_autotester/__init__.py b/autotester/automodpack_autotester/__init__.py deleted file mode 100644 index 54ff7ceb8..000000000 --- a/autotester/automodpack_autotester/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Docker-first AutoModpack in-game test harness.""" - -__all__ = ["__version__"] - -__version__ = "0.1.0" diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client index 13808028c..78947fc4f 100644 --- a/autotester/docker/client/run-headlessmc-client +++ b/autotester/docker/client/run-headlessmc-client @@ -71,26 +71,14 @@ hmc.gameargs=$gameargs EOF # ── Install loader if needed ────────────────────────────────────── +# Only Fabric needs an explicit install step: HMC can only `launch` a named +# version profile, and the `fabric` command is what materialises that profile +# (fabric-loader--). Forge/NeoForge need no install here — HMC +# resolves and installs them on demand from the `:` launch target. launch_target="${loader}:${minecraft}" if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then timeout 30 hmc --command "fabric ${minecraft} --uid ${loader_version}" 2>/dev/null || true launch_target="fabric-loader-${loader_version}-${minecraft}" -#elif [ "$loader" = "forge" ] && [ -n "$loader_version" ]; then -# timeout 30 hmc --command "forge ${minecraft} --uid ${loader_version}" 2>/dev/null || true -# discovered_target="$(discover_launch_target || true)" -# if [ -n "$discovered_target" ]; then -# launch_target="$discovered_target" -# else -# launch_target="${minecraft}-forge-${loader_version}" -# fi -#elif [ "$loader" = "neoforge" ] && [ -n "$loader_version" ]; then -# timeout 30 hmc --command "neoforge ${minecraft} --uid ${loader_version}" 2>/dev/null || true -# discovered_target="$(discover_launch_target || true)" -# if [ -n "$discovered_target" ]; then -# launch_target="$discovered_target" -# else -# launch_target="${minecraft}-neoforge-${loader_version}" -# fi fi # ── Disable Neo/Forge early display window (may cause crash in CI) ── diff --git a/autotester/pyproject.toml b/autotester/pyproject.toml index 5c32cf748..6b6dc4ea8 100644 --- a/autotester/pyproject.toml +++ b/autotester/pyproject.toml @@ -18,3 +18,4 @@ autotester = "automodpack_autotester.cli:main" [tool.setuptools.packages.find] where = ["."] include = ["automodpack_autotester*"] +namespaces = true diff --git a/autotester/settings.yaml b/autotester/settings.yaml index f02c5b35d..97e8e2150 100644 --- a/autotester/settings.yaml +++ b/autotester/settings.yaml @@ -12,7 +12,6 @@ run: target: all scenario: sync jobs: 1 - retryMax: 0 server: memory: 2G diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts index ad8f17ee9..5b0cd2498 100644 --- a/build.neoforge.gradle.kts +++ b/build.neoforge.gradle.kts @@ -17,17 +17,9 @@ neoForge { } } -repositories { - maven("https://maven.su5ed.dev/releases") { name = "FFAPI" } -} - dependencies { implementation(project(":core")) { isTransitive = false } implementation(project(":loader-core")) { isTransitive = false } - - if (sc.current.parsed >= "1.21") { - implementation("org.sinytra.forgified-fabric-api:forgified-fabric-api:0.115.6+2.1.4+1.21.1") - } } tasks { diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java index 08a6eea8c..12cc65ef7 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java @@ -1,10 +1,10 @@ package pl.skidam.automodpack.mixin.core; +import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; import org.spongepowered.asm.mixin.Mixin; /*? if >=1.20.2 {*/ import net.minecraft.network.FriendlyByteBuf; -import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; import net.minecraft.network.protocol.login.custom.CustomQueryPayload; import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Final; @@ -15,10 +15,15 @@ import pl.skidam.automodpack.networking.PayloadHelper; import pl.skidam.automodpack.networking.server.LoginRequestPayload; import pl.skidam.automodpack_core.Constants; +/*?}*/ +// ClientboundCustomQueryPacket exists on every version, so below 1.20.2 the body +// is simply disabled — the readPayload injection only exists from 1.20.2 — +// leaving an intentional no-op mixin. @Mixin(value = ClientboundCustomQueryPacket.class, priority = 300) public class LoginQueryRequestS2CPacketMixin { + /*? if >=1.20.2 {*/ @Shadow @Final private static int MAX_PAYLOAD_SIZE; @Inject(method = "readPayload", at = @At("HEAD"), cancellable = true) @@ -27,12 +32,5 @@ private static void readPayload(Identifier id, FriendlyByteBuf buf, CallbackInfo cir.setReturnValue(new LoginRequestPayload(id, PayloadHelper.read(buf, MAX_PAYLOAD_SIZE))); } } + /*?}*/ } -/*?} else {*/ -/*import net.minecraft.core.BlockPos; - -@Mixin(BlockPos.class) -public class LoginQueryRequestS2CPacketMixin { - // No-op: this mixin is only needed for 1.20.2+ readPayload injection -} -*//*?}*/ \ No newline at end of file diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java index b1b4391db..eb63bc34e 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java @@ -1,10 +1,10 @@ package pl.skidam.automodpack.mixin.core; +import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import org.spongepowered.asm.mixin.Mixin; /*? if >=1.20.2 {*/ import net.minecraft.network.FriendlyByteBuf; -import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import net.minecraft.network.protocol.login.custom.CustomQueryAnswerPayload; import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Final; @@ -15,10 +15,15 @@ import pl.skidam.automodpack.networking.LoginNetworkingIDs; import pl.skidam.automodpack.networking.PayloadHelper; import pl.skidam.automodpack.networking.client.LoginResponsePayload; +/*?}*/ +// Below 1.20.2 the stonecutter replacement rewrites the target to its old name +// (ServerboundCustomQueryPacket) and the body is disabled — the readPayload +// injection only exists from 1.20.2 — leaving an intentional no-op mixin. @Mixin(value = ServerboundCustomQueryAnswerPacket.class, priority = 300) public class LoginQueryResponseC2SPacketMixin { + /*? if >=1.20.2 {*/ @Shadow @Final private static int MAX_PAYLOAD_SIZE; @@ -39,12 +44,5 @@ private static void readResponse(int queryId, FriendlyByteBuf buf, CallbackInfoR cir.setReturnValue(new LoginResponsePayload(automodpackID, PayloadHelper.read(buf, MAX_PAYLOAD_SIZE))); } + /*?}*/ } -/*?} else {*/ -/*import net.minecraft.core.BlockPos; - -@Mixin(BlockPos.class) -public class LoginQueryResponseC2SPacketMixin { - // No-op: this mixin is only needed for 1.20.2+ readPayload injection -} -*//*?}*/ \ No newline at end of file From 1a61ad47cfcfaffc33cff91ba5e9a71cedbbe826 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 27 Jun 2026 11:57:35 +0200 Subject: [PATCH 40/44] remove fapi from forge buildscript --- build.forge.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.forge.gradle.kts b/build.forge.gradle.kts index 6fd228ea9..06200b3d1 100644 --- a/build.forge.gradle.kts +++ b/build.forge.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(project(":core")) { isTransitive = false } implementation(project(":loader-core")) { isTransitive = false } - compileOnly("net.fabricmc.fabric-api:fabric-api:0.92.6+1.20.1") annotationProcessor("org.spongepowered:mixin:0.8.5:processor") // Required to generate refmaps } From c1ab64aa0e3b652b94deb278022eae26634d9fc2 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 27 Jun 2026 12:37:33 +0200 Subject: [PATCH 41/44] autotester: keep test code out of release jars; fix disable-update load + net robustness - Test instrumentation (AutoTestBridge + mixin/dev/*) is excluded from the source set and stripped from the mixin config in normal builds, and only bundled with -Pautomodpack.autotest. build.yml gains an `autotest` input; the in-game-tests workflow and README build with it. Verified: fabric & neoforge release jars contain none of it; autotest-flagged jars do. - Fix "disable update-on-launch" so the installed modpack still loads: split a pure ModpackUpdater.loadModpack() (no server contact, no file reconciliation) out of checkAndLoadModpack and route Preload's updates-disabled path to it, so a binary search no longer loses or rewrites the modpack. - DownloadClient: close every connection opened during pool hydration if any parallel connect fails (was leaking sockets + non-daemon threads); run the blocking probe / cert prompt / login continuation on a dedicated daemon executor instead of ForkJoinPool.commonPool (DownloadClient, ModpackUtils, DataC2SPacket). - Pin the HeadlessMC fork to a commit SHA; the client Dockerfile now fetches by ref (branch/tag/SHA) for reproducible autotester builds. Claude-Session: https://claude.ai/code/session_01AQ1GKvoVqnwharKmXpbwSz --- .github/workflows/build.yml | 13 ++- .github/workflows/ingame-tests.yml | 2 + autotester/README.md | 8 +- autotester/docker/client/Dockerfile | 7 +- autotester/settings.yaml | 7 +- .../main/kotlin/automodpack.common.gradle.kts | 26 ++++++ .../protocol/DownloadClient.java | 57 +++++++++--- .../automodpack_loader_core/Preload.java | 28 +++--- .../client/ModpackUpdater.java | 89 +++++++++++-------- .../client/ModpackUtils.java | 2 +- .../networking/packet/DataC2SPacket.java | 3 +- 11 files changed, 171 insertions(+), 71 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffe1a1eb2..da5a27898 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,11 @@ on: type: string required: false default: '' + autotest: + description: bundle the autotester instrumentation into the jars (never for releases) + type: boolean + required: false + default: false jobs: build: @@ -31,13 +36,17 @@ jobs: - name: Execute Gradle build run: | chmod +x gradlew + flags="" + if [ "${{ inputs.autotest }}" = "true" ]; then + flags="-Pautomodpack.autotest" + fi if [ -z "${{ inputs.target_subproject }}" ]; then echo "Building all subprojects" - ./gradlew build + ./gradlew build $flags else args=$(echo "${{ inputs.target_subproject }}" | tr ',' '\n' | sed 's/$/:build/' | paste -sd ' ') echo "Building with arguments=$args" - ./gradlew $args + ./gradlew $args $flags fi env: BUILD_ID: ${{ github.run_number }} diff --git a/.github/workflows/ingame-tests.yml b/.github/workflows/ingame-tests.yml index 908e6ed46..7e62164a0 100644 --- a/.github/workflows/ingame-tests.yml +++ b/.github/workflows/ingame-tests.yml @@ -39,6 +39,8 @@ jobs: build: needs: [prepare] uses: ./.github/workflows/build.yml + with: + autotest: true secrets: inherit ingame: diff --git a/autotester/README.md b/autotester/README.md index 268c9c101..3bfaf541c 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -11,10 +11,14 @@ opt-in file bridge, and verifies that the modpack sync flow works end to end. - `uv` - Built AutoModpack artifacts in `merged/` -Build artifacts first from the repository root: +Build artifacts first from the repository root. Use `build` (which runs the jar +merge as a finalizer) rather than `mergeJar` directly — `mergeJar` on its own +does not rebuild the dependency modules. The `-Pautomodpack.autotest` flag bundles +the in-game test instrumentation (`AutoTestBridge` + the `dev` mixins) into the +jars; it is required for the autotester and is never included in release builds. ```bash -./gradlew mergeJar +./gradlew build -Pautomodpack.autotest ``` ## Quick Start diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile index 7ff1ee504..e188a25e0 100644 --- a/autotester/docker/client/Dockerfile +++ b/autotester/docker/client/Dockerfile @@ -15,7 +15,12 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /build -RUN git clone --depth 1 --branch "${HEADLESSMC_REF}" "${HEADLESSMC_REPO}" . \ +# Fetch by ref so HEADLESSMC_REF can be a branch, tag, or commit SHA (a shallow +# `clone --branch` only accepts branch/tag names — fetching FETCH_HEAD also takes SHAs). +RUN git init -q . \ + && git remote add origin "${HEADLESSMC_REPO}" \ + && git fetch --depth 1 origin "${HEADLESSMC_REF}" \ + && git checkout -q FETCH_HEAD \ && ./gradlew --no-daemon headlessmc-launcher-wrapper:build -x test \ && jar="$(find headlessmc-launcher-wrapper/build/libs -maxdepth 1 \ -name 'headlessmc-launcher-wrapper-*.jar' \ diff --git a/autotester/settings.yaml b/autotester/settings.yaml index 97e8e2150..a0b024763 100644 --- a/autotester/settings.yaml +++ b/autotester/settings.yaml @@ -38,9 +38,12 @@ serverTypes: headlessmc: # Git repository and ref the HeadlessMC launcher is built from. The default # builds the patch required to launch MC 26.2 headlessly; point these at a - # different repo/ref to use another HeadlessMC build. + # different repo/ref to use another HeadlessMC build. ref may be a branch, tag + # or commit SHA — pinned to a SHA here for reproducible builds (this is the + # head of the mc26.2-headless branch; bump it when the fork moves, or switch + # to the upstream tag once the patch is merged upstream). repo: "https://github.com/Skidamek/headlessmc.git" - ref: "mc26.2-headless" + ref: "64d3c126e72bbfccf95e71afaa6536f50bc64097" timeouts: serverStartSeconds: 180 diff --git a/buildSrc/src/main/kotlin/automodpack.common.gradle.kts b/buildSrc/src/main/kotlin/automodpack.common.gradle.kts index 18fa49a81..882366761 100644 --- a/buildSrc/src/main/kotlin/automodpack.common.gradle.kts +++ b/buildSrc/src/main/kotlin/automodpack.common.gradle.kts @@ -1,11 +1,37 @@ import java.security.MessageDigest import java.math.BigInteger +import org.gradle.api.tasks.SourceSetContainer plugins { idea id("dev.luna5ama.jar-optimizer") } +// Test-only instrumentation (AutoTestBridge + the dev mixins that drive it) is +// compiled into the mod only for autotester builds (-Pautomodpack.autotest); it +// must never ship in release jars. Exclude it from the source set so it lands in +// no jar variant (avoids loom remapJar vs jar pitfalls), and drop the dev mixins +// from the config so Mixin doesn't look for the now-absent classes. +if (!project.hasProperty("automodpack.autotest")) { + plugins.withId("java") { + the().named("main").configure { + java.exclude( + "pl/skidam/automodpack/client/autotest/**", + "pl/skidam/automodpack/mixin/dev/**", + ) + } + } + tasks.named("processResources").configure { + doLast { + val cfg = layout.buildDirectory + .file("resources/main/automodpack-main.mixins.json").get().asFile + if (cfg.exists()) { + cfg.writeText(cfg.readText().replace(Regex(",\\s*\"dev\\.[^\"]*\""), "")) + } + } + } +} + idea { module { isDownloadJavadoc = true diff --git a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java index 1981960f3..f926465e3 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java +++ b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java @@ -31,6 +31,16 @@ public class DownloadClient implements AutoCloseable { + // Dedicated daemon pool for the blocking network/login stages (pool + // hydration, and the up-to-120s certificate prompt). Keeps long downloads + // and that prompt off ForkJoinPool.commonPool, which is shared JVM-wide and + // also drives the parallel streams used here and in ModpackContent. + public static final ExecutorService NET_EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "automodpack-net"); + t.setDaemon(true); + return t; + }); + private final List connections = new ArrayList<>(); private record InitialConnectionResult(PreValidationConnection connection, SSLContext sslContext) {} @@ -75,7 +85,7 @@ public static CompletableFuture createAsync( X509Certificate untrusted = (chain != null && chain.length > 0) ? chain[0] : null; return new ProbeResult(null, untrusted, e, keyStore); } - }).thenCompose(pr -> { + }, NET_EXECUTOR).thenCompose(pr -> { if (pr.success != null) { try { return CompletableFuture.completedFuture( @@ -104,8 +114,8 @@ public static CompletableFuture createAsync( } catch (Exception e) { throw new CompletionException(new IOException("Failed to reconnect after trust", e)); } - }); - }, ForkJoinPool.commonPool()) + }, NET_EXECUTOR); + }, NET_EXECUTOR) .orTimeout(120, TimeUnit.SECONDS) .exceptionally(e -> { throw new CompletionException(new IOException("Certificate not trusted", e)); @@ -132,20 +142,39 @@ private static List hydratePool(InitialConnectionResult probe, byte[ int remainingNeeded = poolSize - conns.size(); if (remainingNeeded < 1) return conns; - List newConnections = IntStream.range(0, remainingNeeded) - .parallel() - .mapToObj(i -> { - try { - return new Connection(getPreValidationConnection(addresses, probe.sslContext()), secretBytes); - } catch (IOException e) { - throw new CompletionException(e); - } - }) - .toList(); - conns.addAll(newConnections); + // Open the remaining connections in parallel. Record each one the moment + // it is created so that if any sibling task fails, we can close every + // connection that did open (including the probe) instead of leaking its + // socket and single-thread executor. + List opened = Collections.synchronizedList(new ArrayList<>()); + try { + IntStream.range(0, remainingNeeded) + .parallel() + .forEach(i -> { + try { + opened.add(new Connection(getPreValidationConnection(addresses, probe.sslContext()), secretBytes)); + } catch (IOException e) { + throw new CompletionException(e); + } + }); + } catch (RuntimeException e) { + for (Connection c : conns) closeQuietly(c); + for (Connection c : opened) closeQuietly(c); + Throwable cause = (e instanceof CompletionException && e.getCause() != null) ? e.getCause() : e; + if (cause instanceof IOException io) throw io; + throw new IOException("Failed to hydrate connection pool", cause); + } + conns.addAll(opened); return conns; } + private static void closeQuietly(AutoCloseable c) { + try { + c.close(); + } catch (Exception ignored) { + } + } + private static KeyStore loadDefaultKeyStore() { try { KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java index bb0531303..1fafe06b0 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java @@ -65,8 +65,22 @@ private void updateAll() { LegacyClientCacheUtils.deleteDummyFiles(); } else { Secrets.Secret secret = SecretsStore.getClientSecret(clientConfig.selectedModpack); - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(selectedModpackAddress, selectedServerAddress, requiresMagic); + + // When update-on-launch is disabled, just load the already-installed + // modpack: don't contact the server and don't reconcile local files, + // so the user can freely add/remove mods (e.g. a binary search). + if (!clientConfig.updateSelectedModpackOnLaunch) { + LegacyClientCacheUtils.deleteDummyFiles(); + var localModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); + try { + new ModpackUpdater(localModpackContent, modpackAddresses, secret, selectedModpackDir).loadModpack(); + } catch (Exception e) { + LOGGER.error("Failed to load modpack", e); + } + return; + } + var optionalLatestModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, false); var latestModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); @@ -83,17 +97,7 @@ private void updateAll() { // Delete dummy files LegacyClientCacheUtils.deleteDummyFiles(); - var modpackUpdaterInstance = new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir); - - if (clientConfig.updateSelectedModpackOnLaunch) { // Check updates and load the modpack - modpackUpdaterInstance.processModpackUpdate(null); - } else { // Otherwise just load the already-installed modpack - try { - modpackUpdaterInstance.checkAndLoadModpack(); - } catch (Exception e) { - LOGGER.error("Failed to check and load modpack", e); - } - } + new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir).processModpackUpdate(null); } } diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java index d53f3207e..88cd06023 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java @@ -124,6 +124,18 @@ public void checkAndLoadModpack() throws Exception { } } + // Load the already-installed modpack without contacting the server or + // reconciling local files against it. Used when update-on-launch is disabled + // so the user can freely add/remove mods (e.g. a binary search) without + // AutoModpack restoring or deleting them. + public void loadModpack() throws Exception { + if (!Files.exists(modpackDir)) + return; + try (var cache = FileMetadataCache.open(hashCacheDBFile)) { + loadModpackMods(cache); + } + } + private void checkAndLoadModpack(FileMetadataCache cache) throws Exception { if (!Files.exists(modpackDir)) return; @@ -137,46 +149,51 @@ private void checkAndLoadModpack(FileMetadataCache cache) throws Exception { return; } - // Load the modpack excluding mods from standard mods directory without need to restart the game - if (preload) { - Set standardModsHashes; - List modpackMods = List.of(); - - // 1. Collect hashes of existing standard mods into a Set for fast lookup - try (Stream standardModsStream = Files.list(MODS_DIR)) { - standardModsHashes = standardModsStream - .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) // Check extension/type before hashing - .map(cache::getHashOrNull) // Safe wrapper for IOException - .filter(Objects::nonNull) - .collect(Collectors.toSet()); // Use Set for O(1) performance - } catch (IOException e) { - LOGGER.error("Failed to list standard mods directory", e); - standardModsHashes = Collections.emptySet(); - } - - // 2. Filter modpack mods excluding those already present in standard mods - Path modpackModsDir = modpackDir.resolve("mods"); - if (Files.exists(modpackModsDir)) { - try (Stream modpackModsStream = Files.list(modpackModsDir)) { - final Set finalStandardModsHashes = standardModsHashes; - modpackMods = modpackModsStream - .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) - .filter(mod -> { - String modHash = cache.getHashOrNull(mod); - // Only load if hash is valid AND not found in standard set - return modHash != null && !finalStandardModsHashes.contains(modHash); - }) - .toList(); - } catch (IOException e) { - LOGGER.error("Failed to list modpack mods directory", e); - } - } + loadModpackMods(cache); + } - MODPACK_LOADER.loadModpack(modpackMods); + // Load the modpack mods that aren't already present in the standard mods + // directory, without requiring a restart. + private void loadModpackMods(FileMetadataCache cache) throws Exception { + if (!preload) { + LOGGER.info("Modpack is already loaded"); return; } - LOGGER.info("Modpack is already loaded"); + Set standardModsHashes; + List modpackMods = List.of(); + + // 1. Collect hashes of existing standard mods into a Set for fast lookup + try (Stream standardModsStream = Files.list(MODS_DIR)) { + standardModsHashes = standardModsStream + .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) // Check extension/type before hashing + .map(cache::getHashOrNull) // Safe wrapper for IOException + .filter(Objects::nonNull) + .collect(Collectors.toSet()); // Use Set for O(1) performance + } catch (IOException e) { + LOGGER.error("Failed to list standard mods directory", e); + standardModsHashes = Collections.emptySet(); + } + + // 2. Filter modpack mods excluding those already present in standard mods + Path modpackModsDir = modpackDir.resolve("mods"); + if (Files.exists(modpackModsDir)) { + try (Stream modpackModsStream = Files.list(modpackModsDir)) { + final Set finalStandardModsHashes = standardModsHashes; + modpackMods = modpackModsStream + .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) + .filter(mod -> { + String modHash = cache.getHashOrNull(mod); + // Only load if hash is valid AND not found in standard set + return modHash != null && !finalStandardModsHashes.contains(modHash); + }) + .toList(); + } catch (IOException e) { + LOGGER.error("Failed to list modpack mods directory", e); + } + } + + MODPACK_LOADER.loadModpack(modpackMods); } public void startUpdate(Set filesToUpdate) { diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index b9ec11f17..507dd66d6 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -864,7 +864,7 @@ private static CompletableFuture askUserAboutCertificateAsync(InetSocke } catch (Exception e) { return false; } - }); + }, DownloadClient.NET_EXECUTOR); } public static boolean potentiallyMalicious(Jsons.ModpackContentFields serverModpackContent) { diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 43378fad9..24194f927 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -8,6 +8,7 @@ import pl.skidam.automodpack_core.auth.Secrets; import pl.skidam.automodpack_core.auth.SecretsStore; import pl.skidam.automodpack_core.config.Jsons; +import pl.skidam.automodpack_core.protocol.DownloadClient; import pl.skidam.automodpack_core.utils.AddressHelpers; import pl.skidam.automodpack_loader_core.ReLauncher; import pl.skidam.automodpack_loader_core.client.ModpackUpdater; @@ -136,7 +137,7 @@ public static CompletableFuture receive(Minecraft client, Clien } return buildResponse(needsDisconnecting); - }) + }, DownloadClient.NET_EXECUTOR) .exceptionally(e -> { LOGGER.error("Error while handling data packet", e); FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); From d932b1a649d72d6f2638bb41835a5d73121c2f51 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 27 Jun 2026 13:29:01 +0200 Subject: [PATCH 42/44] autotester: declarative scenario engine (verbs, selectors, conditions, macros) Scenarios are now data, not Python phases. A flow is a list of steps, where each step is a generic verb (click / type / wait_for / assert / verify_files / ...) plus arguments, so new tests are written entirely in YAML. Engine (automodpack_autotester/engine/): - registry: @verb decorator + name->fn lookup - context: per-case state, ${...} templating, bridge/log access - selectors: declarative GUI element matching (role/text/class/enabled/index) - conditions: boolean predicates (screen/element/file/log/all/any/not) shared by when:, wait_for.until:, assert.that:; log conditions capture regex groups into vars - steps_ui / steps_io: the UI and filesystem verbs - executor: macro expansion, when-gating, repeat, optional, per-step results runner.py keeps the Docker lifecycle helpers and registers the lifecycle verbs (launch_server, connect, wait_join, ...); run_case builds a Context and runs the flow through the engine, recording per-step results into results.json. Scenarios rewritten declaratively on a shared macro library (scenarios/_lib.yaml: boot, accept_certificate, download_modpack, restart_client, rejoin). Behavior (selectors, screen names, fingerprint regex, connect retry) matches the old phases exactly. Tests (tests/, no Docker): 33 unit + flow tests covering parsing, selectors, conditions, templating, polling, and the executor, plus running the real shipped scenarios/macros through a fake bridge. Verified end to end on real Docker: 1.21.1-fabric sync passes (21 steps, full boot -> sync -> restart -> rejoin). README documents the verb/selector/condition/template/macro model. --- autotester/README.md | 162 ++++- autotester/automodpack_autotester/config.py | 12 +- .../automodpack_autotester/engine/__init__.py | 11 + .../engine/conditions.py | 93 +++ .../automodpack_autotester/engine/context.py | 97 +++ .../automodpack_autotester/engine/executor.py | 89 +++ .../automodpack_autotester/engine/registry.py | 25 + .../engine/selectors.py | 79 ++ .../automodpack_autotester/engine/steps_io.py | 65 ++ .../automodpack_autotester/engine/steps_ui.py | 65 ++ .../automodpack_autotester/engine/util.py | 61 ++ autotester/automodpack_autotester/runner.py | 674 ++++++------------ autotester/pyproject.toml | 7 + autotester/scenarios/_lib.yaml | 92 +++ autotester/scenarios/download-only.yaml | 23 +- autotester/scenarios/sync.yaml | 33 +- autotester/tests/__init__.py | 0 autotester/tests/conftest.py | 132 ++++ autotester/tests/test_flows.py | 145 ++++ autotester/tests/test_unit.py | 245 +++++++ autotester/uv.lock | 69 ++ 21 files changed, 1659 insertions(+), 520 deletions(-) create mode 100644 autotester/automodpack_autotester/engine/__init__.py create mode 100644 autotester/automodpack_autotester/engine/conditions.py create mode 100644 autotester/automodpack_autotester/engine/context.py create mode 100644 autotester/automodpack_autotester/engine/executor.py create mode 100644 autotester/automodpack_autotester/engine/registry.py create mode 100644 autotester/automodpack_autotester/engine/selectors.py create mode 100644 autotester/automodpack_autotester/engine/steps_io.py create mode 100644 autotester/automodpack_autotester/engine/steps_ui.py create mode 100644 autotester/automodpack_autotester/engine/util.py create mode 100644 autotester/scenarios/_lib.yaml create mode 100644 autotester/tests/__init__.py create mode 100644 autotester/tests/conftest.py create mode 100644 autotester/tests/test_flows.py create mode 100644 autotester/tests/test_unit.py diff --git a/autotester/README.md b/autotester/README.md index 3bfaf541c..16f6cf6b2 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -88,10 +88,12 @@ The client image builds its HeadlessMC launcher from the git repo and ref in ```yaml headlessmc: repo: "https://github.com/Skidamek/headlessmc.git" - ref: "mc26.2-headless" + ref: "64d3c126e72bbfccf95e71afaa6536f50bc64097" # branch, tag, or commit SHA ``` -This default ref carries the patch required to launch **MC 26.2** headlessly +`ref` may be a branch, tag, or commit SHA; it is pinned to a SHA here for +reproducible image builds. This default ref carries the patch required to launch +**MC 26.2** headlessly (stock HeadlessMC can't yet — its LWJGL stubs don't satisfy 26.2's new render backend); it does not change behavior on other versions. Point `repo`/`ref` at any other HeadlessMC build and rebuild the image @@ -100,32 +102,25 @@ build falls back to upstream HeadlessMC (`headlesshq/headlessmc`). ## Scenarios -A scenario is an ordered list of registered phases plus optional topology and -server-file configuration: +Scenarios are **declarative**: a `flow` is an ordered list of *steps*, where each +step is a verb plus arguments. Verbs are generic building blocks (click a button, +type into a field, wait for a condition, verify files), so most tests need no +Python — behavior is expressed entirely in YAML. ```yaml id: download-only flow: - - launch_server - - launch_client - - read_fingerprint - - wait_server - - wait_bridge - - connect - - wait_fingerprint - - accept_fingerprint - - wait_download_prompt - - confirm_download - - wait_download - - verify_files - - quit + - use: boot # a macro from scenarios/_lib.yaml + - use: accept_certificate + - use: download_modpack + - do: verify_files # a verb with arguments + name: verify all synced files are present + - do: quit topology: server: memory: 2G - env: - ENABLE_ROLLING_LOGS: "false" serverFiles: modpackName: amp-autotest @@ -135,27 +130,108 @@ serverFiles: content: "hello\n" ``` -Useful phases: +A step is either a bare name (`- quit`, or a macro name) or a mapping with a +`do:` verb and its arguments. Optional keys on any step: -| Phase | Purpose | +| Key | Meaning | | --- | --- | -| `launch_server` | Start the Minecraft server container. | -| `wait_server` | Wait until the server logs `Done (`. | -| `launch_client` | Start a HeadlessMC client container. | -| `wait_bridge` | Wait for the in-game bridge to report the client is ready. | -| `connect` | Connect the client to the test server. | -| `read_fingerprint` | Extract the AutoModpack TLS fingerprint from server logs. | -| `wait_fingerprint` | Wait for a certificate prompt with a text field and Verify button. | -| `accept_fingerprint` | Enter the expected fingerprint and click Verify. | -| `skip_fingerprint` | Skip certificate verification (accept the risk) instead of verifying. | -| `wait_download_prompt` | Wait for the modpack download/confirmation prompt. | -| `confirm_download` | Click the download button to start the sync. | -| `wait_download` | Wait for the marker file in the synced modpack. | -| `verify_files` | Verify all configured `serverFiles.files` exist on the client. | -| `verify_mods` | Verify all configured `serverFiles.expectedMods` exist on the client. | -| `confirm_restart` | Click restart/quit on the restart screen if shown. | -| `wait_join` | Verify the client reaches the in-game state. | -| `quit` | Stop the client through the bridge. | +| `name` | Human-readable label shown in logs and `results.json`. | +| `when` | A condition; the step runs only if it holds. | +| `repeat` | Run the step N times. | +| `optional` | If the step fails, log it and continue instead of failing the run. | + +### Verbs + +| Verb | Purpose | +| --- | --- | +| `click` | Click the element matched by `select:` (defaults to enabled elements). Set `enable: true` to force-enable it first. | +| `type` / `paste` | Type `value:` into the field matched by `select:` (defaults to the first text field). | +| `wait_for` | Poll `until:` (a condition) until it holds or `timeout:` elapses. | +| `assert` | Fail immediately unless `that:` (a condition) holds. | +| `sleep` | Wait `duration:` (e.g. `2s`). | +| `wait_file` / `wait_files` | Wait for file(s) under the client game dir. | +| `verify_files` | Wait until every `serverFiles.files` entry exists in the synced modpack. | +| `verify_mods` | Wait until every `serverFiles.expectedMods` glob is present. | +| `launch_server` / `wait_server` | Start the server / wait for `Done (`. | +| `launch_client` / `relaunch_client` / `wait_bridge` | Start a client / wait for its bridge. | +| `connect` / `disconnect` / `quit` | Drive the client connection. | +| `wait_client_exit` | Wait for the client container to exit (after a restart). | +| `wait_join` | Wait until the player is in-game (no screen open). | + +### Selectors + +`select:` (and the `element` condition) match GUI elements declaratively: + +```yaml +select: + role: button # button | textfield | any (default) + text: Verify # exact match preferred, else substring (case-insensitive) + text_any: [ok, yes] # any of these + class: Btn # substring of the element's class + enabled: true # filter by enabled / visible + index: -1 # pick the Nth match (negative counts from the end) +``` + +### Conditions + +`when:`, `wait_for.until:`, and `assert.that:` all take a condition. All keys in a +condition must hold (AND): + +| Key | True when | +| --- | --- | +| `screen` / `screen_not` | The current screen class/title contains (any of) the given value(s). | +| `screen_none` | No screen is open (the player is in-game). | +| `element` / `no_element` | A selector matches at least one / no elements. | +| `file` / `file_gone` | A path under the game dir exists / does not exist. | +| `log` | A regex matches a container log; capture groups into vars (see below). | +| `all` / `any` / `not` | Combine sub-conditions. | + +### Templating and variables + +Strings expand `${...}` against the scenario context: `${target.id}`, +`${server.host}`, `${modpack}`, `${marker}`, plus any captured variables. The +`log` condition can capture regex groups into variables for later steps: + +```yaml +- do: wait_for + name: read fingerprint + until: + log: + container: server + matches: 'fingerprint[:\s]+([0-9A-Fa-f:]+)' + capture: { fingerprint: 1 } +- do: type + select: { role: textfield } + value: "${fingerprint}" # captured above +``` + +### Macros + +Reusable step sequences live in `scenarios/_lib.yaml` and are referenced by name +(`- use: boot`, or inline as a bare string). A scenario can also define local +sequences under a `sequences:` key. Files starting with `_` are libraries and are +never run as scenarios. The shipped library provides `boot`, +`read_server_fingerprint`, `connect_to_server`, `accept_certificate`, +`download_modpack`, `restart_client`, and `rejoin`. + +### Adding a new verb + +Generic verbs cover most needs. When you genuinely need new behavior, register a +verb in `automodpack_autotester/engine/` (UI/filesystem verbs) or in `runner.py` +(verbs that touch Docker): + +```python +from automodpack_autotester.engine.registry import verb + +@verb("my_step") +def my_step(ctx, step): + target = ctx.resolve(step["arg"]) # expand templates + ctx.gui() # current GUI snapshot + ctx.bridge.click(...) # drive the client +``` + +Engine internals (selectors, conditions, templating, the executor) are covered by +`tests/` and run without Docker: `uv --project autotester run --group dev pytest`. ## Output @@ -179,12 +255,20 @@ Important files: "scenario": "sync", "ok": false, "duration": 142.7, - "error": "Download marker file ... did not appear" + "error": "step 'confirm download' failed: ...", + "steps": [ + { "name": "start server", "verb": "launch_server", "ok": true, "duration": 0.1 }, + { "name": "confirm download", "verb": "click", "ok": false, "duration": 90.0, "error": "..." } + ] } ] } ``` +Each step records its `name`, `verb`, `ok`, and `duration`; failed steps also +carry an `error`. On failure the partial step list up to and including the failing +step is preserved. + ## CI Workflow `.github/workflows/ingame-tests.yml` is manual (`workflow_dispatch`). It builds diff --git a/autotester/automodpack_autotester/config.py b/autotester/automodpack_autotester/config.py index fc0b523cc..7b505eb82 100644 --- a/autotester/automodpack_autotester/config.py +++ b/autotester/automodpack_autotester/config.py @@ -60,4 +60,14 @@ def load_targets() -> dict[str, Target]: def load_scenarios() -> dict[str, dict]: - return {f.stem: load_yaml(f) for f in sorted((ROOT / "scenarios").glob("*.yaml"))} + return { + f.stem: load_yaml(f) + for f in sorted((ROOT / "scenarios").glob("*.yaml")) + if not f.name.startswith("_") + } + + +def load_macros() -> dict: + """Shared reusable step sequences from ``scenarios/_lib.yaml`` (if present).""" + lib = ROOT / "scenarios" / "_lib.yaml" + return load_yaml(lib) if lib.is_file() else {} diff --git a/autotester/automodpack_autotester/engine/__init__.py b/autotester/automodpack_autotester/engine/__init__.py new file mode 100644 index 000000000..1d5f0504f --- /dev/null +++ b/autotester/automodpack_autotester/engine/__init__.py @@ -0,0 +1,11 @@ +"""Declarative step engine for autotester scenarios. + +Importing this package registers the built-in UI and filesystem verbs. The runner +registers the remaining lifecycle verbs (launch/connect/quit/...) when it loads. +""" +from . import steps_io, steps_ui # noqa: F401 -- import for verb registration +from .context import ClientExited, Context +from .executor import run_flow +from .registry import get, verb + +__all__ = ["Context", "ClientExited", "run_flow", "verb", "get"] diff --git a/autotester/automodpack_autotester/engine/conditions.py b/autotester/automodpack_autotester/engine/conditions.py new file mode 100644 index 000000000..9275d6333 --- /dev/null +++ b/autotester/automodpack_autotester/engine/conditions.py @@ -0,0 +1,93 @@ +"""Boolean conditions, shared by ``wait_for``, ``assert`` and ``when``. + +A condition is a mapping; all keys must hold (AND). Keys: + + screen screenClass/title contains (str or list -> any) + screen_not none of these + screen_none true -> in-game (no screen open) + element a selector that must match at least one element + no_element a selector that must match nothing + file path (relative to the client game dir) exists + file_gone path does not exist + log {container: server|client, matches: , capture: {var: group}} + all / any combine a list of sub-conditions + not negate a sub-condition +""" +from __future__ import annotations + +import re + +from . import selectors + +_GUI_KEYS = {"screen", "screen_not", "screen_none", "element", "no_element"} + + +def evaluate(ctx, cond: dict, gui: dict | None = None) -> bool: + if not cond: + return True + if gui is None and _needs_gui(cond): + gui = ctx.gui() + return all(_check(ctx, key, val, gui) for key, val in cond.items()) + + +def describe(cond: dict) -> str: + return repr(cond) + + +def _needs_gui(cond: dict) -> bool: + for key, val in cond.items(): + if key in _GUI_KEYS: + return True + if key in ("all", "any") and any(_needs_gui(s) for s in val): + return True + if key == "not" and _needs_gui(val): + return True + return False + + +def _check(ctx, key, val, gui) -> bool: + if key == "all": + return all(evaluate(ctx, sub, gui) for sub in val) + if key == "any": + return any(evaluate(ctx, sub, gui) for sub in val) + if key == "not": + return not evaluate(ctx, val, gui) + if key == "screen": + return _screen(gui, val) + if key == "screen_not": + return not _screen(gui, val) + if key == "screen_none": + return (gui.get("screenClass") is None) == bool(val) + if key == "element": + return selectors.find_one(gui, ctx.resolve(val)) is not None + if key == "no_element": + return selectors.find_one(gui, ctx.resolve(val)) is None + if key == "file": + return ctx.path(val).exists() + if key == "file_gone": + return not ctx.path(val).exists() + if key == "log": + return _log(ctx, val) + raise ValueError(f"unknown condition key: {key!r}") + + +def _screen(gui: dict, val) -> bool: + sc = str(gui.get("screenClass") or "") + title = str(gui.get("title") or "") + needles = [val] if isinstance(val, str) else list(val) + return any(str(n) in sc or str(n) in title for n in needles) + + +def _log(ctx, spec: dict) -> bool: + which = str(spec.get("container", "client")) + pattern = ctx.resolve(str(spec["matches"])) + logs = ctx.container_logs(which, spec.get("tail", 400)) + m = re.search(pattern, logs, re.IGNORECASE | re.MULTILINE) + if not m: + return False + for var, group in (spec.get("capture") or {}).items(): + try: + ctx.vars[str(var)] = m.group(int(group)) + except (IndexError, ValueError): + pass + return True diff --git a/autotester/automodpack_autotester/engine/context.py b/autotester/automodpack_autotester/engine/context.py new file mode 100644 index 000000000..5b4bb52cf --- /dev/null +++ b/autotester/automodpack_autotester/engine/context.py @@ -0,0 +1,97 @@ +"""Execution context shared by every step: data, variables, templating, bridge access.""" +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from ..bridge import BridgeClient +from .util import ClientExited + +_VAR = re.compile(r"\$\{([^}]+)\}") + + +@dataclass +class Context: + """Everything a step needs. Built once per test case by the runner.""" + + target: Any + scenario: dict + settings: dict + game_dir: Path + server_dir: Path + case_dir: Path + out_dir: Path + client_image: str + srv_name: str + cli_name: str + net_name: str + token: str + artifact: Path + modpack_name: str + marker_rel: Path + scenario_files: list # list[(Path, str)] + expected_mods: list + vars: dict = field(default_factory=dict) + bridge: BridgeClient | None = None + # Injected by the runner so the engine stays decoupled from Docker. + logs_provider: Callable[[str, int | None], str] | None = None + running_provider: Callable[[], None] | None = None + + # --- variables / templating ------------------------------------------- + + def namespace(self) -> dict: + return { + "target": self.target, + "server": {"host": self.srv_name, "port": 25565}, + "client": {"game_dir": str(self.game_dir)}, + "modpack": self.modpack_name, + "marker": str(self.marker_rel), + **self.vars, + } + + def resolve(self, value: Any) -> Any: + """Recursively expand ``${var}`` / ``${var.attr}`` in strings, lists, dicts.""" + if isinstance(value, str): + ns = self.namespace() + return _VAR.sub(lambda m: str(_lookup(ns, m.group(1).strip())), value) + if isinstance(value, list): + return [self.resolve(v) for v in value] + if isinstance(value, dict): + return {k: self.resolve(v) for k, v in value.items()} + return value + + # --- bridge / containers --------------------------------------------- + + def gui(self, timeout: float = 30) -> dict: + self.assert_client_running() + if self.bridge is None: + raise RuntimeError("bridge not ready (run wait_bridge first)") + return self.bridge.gui(timeout=timeout) + + def assert_client_running(self) -> None: + if self.running_provider is not None: + self.running_provider() + + def container_logs(self, which: str = "client", tail: int | None = None) -> str: + if self.logs_provider is None: + return "" + return self.logs_provider(which, tail) + + def path(self, rel: str) -> Path: + return self.game_dir / self.resolve(str(rel)) + + +def _lookup(ns: dict, dotted: str) -> Any: + parts = dotted.split(".") + if parts[0] not in ns: + raise KeyError(f"unknown template variable: ${{{dotted}}}") + cur: Any = ns[parts[0]] + for part in parts[1:]: + cur = cur.get(part) if isinstance(cur, dict) else getattr(cur, part, None) + return "" if cur is None else cur + + +__all__ = ["Context", "ClientExited"] diff --git a/autotester/automodpack_autotester/engine/executor.py b/autotester/automodpack_autotester/engine/executor.py new file mode 100644 index 000000000..67f5948b4 --- /dev/null +++ b/autotester/automodpack_autotester/engine/executor.py @@ -0,0 +1,89 @@ +"""Run a scenario flow: expand macros, gate on ``when``, execute verbs, record results.""" +from __future__ import annotations + +import logging +import time + +from . import conditions +from .registry import get as get_verb + +logger = logging.getLogger(__name__) + + +def run_flow(ctx, scenario: dict, *, lib: dict | None = None, results: list | None = None) -> list[dict]: + """Execute ``scenario['flow']`` and return a per-step result list. + + Pass ``results`` to collect step records into a caller-owned list, so partial + results survive when the flow raises partway through. + """ + macros = dict(lib or {}) + macros.update(scenario.get("sequences") or {}) + flow = scenario.get("flow") + if not flow: + raise ValueError("scenario has no 'flow'") + if results is None: + results = [] + _run_steps(ctx, flow, macros, results) + return results + + +def _run_steps(ctx, steps, macros, results, depth=0): + if depth > 25: + raise RuntimeError("macro expansion too deep (cycle in sequences?)") + for raw in steps: + if isinstance(raw, str): + if raw in macros: + _run_steps(ctx, macros[raw], macros, results, depth + 1) + continue + step = {"do": raw} + elif isinstance(raw, dict): + step = dict(raw) + else: + raise ValueError(f"invalid step: {raw!r}") + + if "use" in step: + name = step["use"] + if name not in macros: + raise ValueError(f"unknown macro: {name!r}") + _run_steps(ctx, macros[name], macros, results, depth + 1) + continue + if "group" in step: + _run_steps(ctx, step.get("steps", []), macros, results, depth + 1) + continue + + when = step.get("when") + if when is not None and not conditions.evaluate(ctx, ctx.resolve(when)): + logger.info("[%s] skip (when not met): %s", _tid(ctx), step.get("name") or step.get("do")) + continue + + for _ in range(int(step.get("repeat", 1))): + _run_one(ctx, step, results) + + +def _run_one(ctx, step, results): + verb_name = step.get("do") + fn = get_verb(verb_name) + if fn is None: + raise ValueError(f"unknown step verb: {verb_name!r}") + label = step.get("name") or verb_name + started = time.monotonic() + logger.info("[%s] step: %s", _tid(ctx), label) + try: + fn(ctx, step) + except Exception as e: + results.append({ + "name": label, "verb": verb_name, "ok": False, + "duration": time.monotonic() - started, "error": str(e), + }) + if step.get("optional"): + logger.warning("[%s] optional step '%s' failed: %s", _tid(ctx), label, e) + return + raise RuntimeError(f"step '{label}' failed: {e}") from e + results.append({ + "name": label, "verb": verb_name, "ok": True, + "duration": time.monotonic() - started, + }) + + +def _tid(ctx): + return getattr(ctx.target, "id", "?") diff --git a/autotester/automodpack_autotester/engine/registry.py b/autotester/automodpack_autotester/engine/registry.py new file mode 100644 index 000000000..62d3899ae --- /dev/null +++ b/autotester/automodpack_autotester/engine/registry.py @@ -0,0 +1,25 @@ +"""Verb registry. Step verbs register here and the executor looks them up by name.""" +from __future__ import annotations + +from collections.abc import Callable + +VERBS: dict[str, Callable] = {} + + +def verb(*names: str) -> Callable: + """Register a step handler under one or more verb names. + + A handler has the signature ``fn(ctx, step) -> None`` where ``step`` is the + raw step mapping from the scenario. + """ + + def deco(fn: Callable) -> Callable: + for name in names: + VERBS[name] = fn + return fn + + return deco + + +def get(name: str) -> Callable | None: + return VERBS.get(name) diff --git a/autotester/automodpack_autotester/engine/selectors.py b/autotester/automodpack_autotester/engine/selectors.py new file mode 100644 index 000000000..e925d2dcf --- /dev/null +++ b/autotester/automodpack_autotester/engine/selectors.py @@ -0,0 +1,79 @@ +"""Match GUI elements from a bridge snapshot by a declarative selector. + +A selector is a mapping; all given fields must match (AND): + + role button | textfield | any (default: any) + text exact (case-insensitive, trimmed) preferred, else substring + text_any list of texts, any may match + class substring of the element's class name + enabled true/false + visible true/false + index pick the Nth match (default 0; negative counts from the end) +""" +from __future__ import annotations + + +def _elements(gui: dict, role: str) -> list: + role = role.lower() + if role in ("button", "buttons"): + return list(gui.get("buttons", [])) + if role in ("textfield", "textfields", "field"): + return list(gui.get("textFields", [])) + return list(gui.get("buttons", [])) + list(gui.get("textFields", [])) + + +def _needles(selector: dict) -> list[str] | None: + if selector.get("text") is not None: + return [str(selector["text"])] + if selector.get("text_any") is not None: + return [str(t) for t in selector["text_any"]] + return None + + +def _exact(element: dict, needles: list[str]) -> bool: + text = str(element.get("text", "")).strip().lower() + return any(text == n.strip().lower() for n in needles) + + +def _matches_text(element: dict, needles: list[str]) -> bool: + if _exact(element, needles): + return True + text = str(element.get("text", "")).lower() + return any(n.strip().lower() in text for n in needles) + + +def find_all(gui: dict, selector: dict) -> list: + role = str(selector.get("role", "any")) + needles = _needles(selector) + klass = selector.get("class") + enabled = selector.get("enabled") + visible = selector.get("visible") + out = [] + for e in _elements(gui, role): + if enabled is not None and bool(e.get("enabled", False)) != bool(enabled): + continue + if visible is not None and bool(e.get("visible", False)) != bool(visible): + continue + if klass is not None and str(klass).lower() not in str(e.get("class", "")).lower(): + continue + if needles is not None and not _matches_text(e, needles): + continue + out.append(e) + return out + + +def find_one(gui: dict, selector: dict): + matches = find_all(gui, selector) + if not matches: + return None + if "index" in selector: + idx = int(selector["index"]) + if idx < 0: + idx += len(matches) + return matches[idx] if 0 <= idx < len(matches) else None + needles = _needles(selector) + if needles: + for e in matches: # prefer an exact text match over a substring one + if _exact(e, needles): + return e + return matches[0] diff --git a/autotester/automodpack_autotester/engine/steps_io.py b/autotester/automodpack_autotester/engine/steps_io.py new file mode 100644 index 000000000..11abcb7c5 --- /dev/null +++ b/autotester/automodpack_autotester/engine/steps_io.py @@ -0,0 +1,65 @@ +"""Filesystem verbs: wait_file, wait_files, verify_files, verify_mods. + +Log-based waits are expressed with ``wait_for`` + a ``log`` condition, so no +dedicated verb is needed for them. +""" +from __future__ import annotations + +from fnmatch import fnmatch + +from .registry import verb +from .util import await_condition, parse_duration + + +@verb("wait_file") +def wait_file(ctx, step): + rel = ctx.resolve(str(step["path"])) + p = ctx.game_dir / rel + timeout = parse_duration(step.get("timeout"), default=300) + await_condition( + lambda: p if p.exists() else None, + timeout, + step.get("poll"), + f"file {rel} did not appear", + ) + + +@verb("wait_files") +def wait_files(ctx, step): + root = ctx.game_dir / ctx.resolve(str(step.get("root", ""))) + paths = [ctx.resolve(str(p)) for p in step.get("paths", [])] + timeout = parse_duration(step.get("timeout"), default=120) + + def _all(): + return True if all((root / p).exists() for p in paths) else None + + missing_msg = f"files did not all appear under {root}" + await_condition(_all, timeout, step.get("poll"), missing_msg) + + +@verb("verify_files") +def verify_files(ctx, step): + """Wait until every file declared in the scenario's ``serverFiles`` is present.""" + root = ctx.game_dir / ctx.resolve(str(step.get("root", "automodpack/modpacks/${modpack}"))) + rels = [str(rel) for rel, _ in ctx.scenario_files] + timeout = parse_duration(step.get("timeout"), default=120) + + def _all(): + return True if all((root / r).exists() for r in rels) else None + + await_condition(_all, timeout, step.get("poll"), f"modpack files missing under {root}") + + +@verb("verify_mods") +def verify_mods(ctx, step): + if not ctx.expected_mods: + return + mod_dir = ctx.game_dir / ctx.resolve(str(step.get("root", "automodpack/modpacks/${modpack}/mods"))) + timeout = parse_duration(step.get("timeout"), default=120) + + def _all(): + mods = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() + ok = all(any(fnmatch(m, pat) for m in mods) for pat in ctx.expected_mods) + return True if ok else None + + await_condition(_all, timeout, step.get("poll"), "expected mods missing") diff --git a/autotester/automodpack_autotester/engine/steps_ui.py b/autotester/automodpack_autotester/engine/steps_ui.py new file mode 100644 index 000000000..fadcef997 --- /dev/null +++ b/autotester/automodpack_autotester/engine/steps_ui.py @@ -0,0 +1,65 @@ +"""UI verbs: click, type/paste, wait_for, assert, sleep.""" +from __future__ import annotations + +import time + +from . import conditions, selectors +from .registry import verb +from .util import await_condition, parse_duration + + +@verb("click") +def click(ctx, step): + selector = dict(ctx.resolve(step.get("select") or {})) + if "enabled" not in selector: + selector["enabled"] = True # by default only click clickable elements + timeout = parse_duration(step.get("timeout"), default=30) + el = await_condition( + lambda: selectors.find_one(ctx.gui(), selector), + timeout, + step.get("poll"), + f"no element matched {selector!r}", + ) + if step.get("enable"): + ctx.bridge.click(int(el["id"]), enable=True) + else: + ctx.bridge.click(int(el["id"])) + + +@verb("type", "paste") +def type_(ctx, step): + selector = dict(ctx.resolve(step.get("select") or {"role": "textfield"})) + value = str(ctx.resolve(step.get("value", ""))) + timeout = parse_duration(step.get("timeout"), default=30) + el = await_condition( + lambda: selectors.find_one(ctx.gui(), selector), + timeout, + step.get("poll"), + f"no text field matched {selector!r}", + ) + ctx.bridge.text(int(el["id"]), value) + + +@verb("wait_for") +def wait_for(ctx, step): + cond = step.get("until") or {} + timeout = parse_duration(step.get("timeout"), default=60) + await_condition( + lambda: True if conditions.evaluate(ctx, cond) else None, + timeout, + step.get("poll"), + f"condition not met: {conditions.describe(cond)}", + ) + + +@verb("assert") +def assert_(ctx, step): + cond = step.get("that") or step.get("until") or {} + if not conditions.evaluate(ctx, cond): + raise AssertionError(f"assertion failed: {conditions.describe(cond)}") + + +@verb("sleep") +def sleep(ctx, step): + dur = parse_duration(step.get("duration") or step.get("seconds"), default=1) + time.sleep(dur or 0) diff --git a/autotester/automodpack_autotester/engine/util.py b/autotester/automodpack_autotester/engine/util.py new file mode 100644 index 000000000..4529b470b --- /dev/null +++ b/autotester/automodpack_autotester/engine/util.py @@ -0,0 +1,61 @@ +"""Shared helpers: duration parsing and the polling primitive used by every wait.""" +from __future__ import annotations + +import random +import time +from collections.abc import Callable +from typing import Any + + +class ClientExited(RuntimeError): + """The client container is gone. Raised through waits instead of being retried.""" + + +def parse_duration(value: Any, default: float | None = None) -> float | None: + """Parse ``90s`` / ``3m`` / ``500ms`` / ``180`` into seconds.""" + if value is None: + return float(default) if default is not None else None + if isinstance(value, (int, float)): + return float(value) + s = str(value).strip().lower() + try: + if s.endswith("ms"): + return float(s[:-2]) / 1000.0 + if s.endswith("s"): + return float(s[:-1]) + if s.endswith("m"): + return float(s[:-1]) * 60.0 + if s.endswith("h"): + return float(s[:-1]) * 3600.0 + return float(s) + except ValueError: + return float(default) if default is not None else None + + +def await_condition( + pred: Callable[[], Any], + timeout: float | None, + poll: Any = None, + msg: str = "condition not met", +) -> Any: + """Poll ``pred`` until it returns a non-None value or the timeout elapses. + + Transient bridge errors (``TimeoutError`` / ``RuntimeError``) are swallowed and + retried; :class:`ClientExited` is always re-raised so a dead client fails fast. + Scenario errors (``ValueError``/``AssertionError``) propagate immediately. + """ + interval = parse_duration(poll, default=0.5) or 0.5 + deadline = time.monotonic() + (timeout if timeout is not None else 60.0) + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + result = pred() + if result is not None: + return result + except ClientExited: + raise + except (TimeoutError, RuntimeError) as e: + last_err = e + time.sleep(interval * random.uniform(0.8, 1.2)) + suffix = f" (last error: {last_err})" if last_err else "" + raise TimeoutError(f"{msg} within {timeout}s{suffix}") diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index f24dd2895..b3f911871 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -4,18 +4,18 @@ import logging import os import random -import re import secrets import shutil import time -from collections.abc import Callable -from fnmatch import fnmatch from pathlib import Path import docker as docker_py from .bridge import BridgeClient -from .config import Target +from .config import Target, load_macros +from .engine import ClientExited, Context, run_flow +from .engine.registry import verb +from .engine.util import await_condition, parse_duration logger = logging.getLogger(__name__) @@ -23,6 +23,9 @@ _docker = docker_py.from_env() +# ── low-level docker / container helpers ────────────────────────────────── + + def _jitter_sleep(base, fraction=0.2): time.sleep(random.uniform(base * (1 - fraction), base * (1 + fraction))) @@ -119,16 +122,6 @@ def _wait_exited(name, timeout): raise TimeoutError(f"Timeout waiting for {name} to exit") -PHASES: dict[str, Callable] = {} - - -def _reg(name: str) -> Callable: - def wrapper(fn: Callable) -> Callable: - PHASES[name] = fn - return fn - return wrapper - - def _uid(): return int(os.environ.get("AUTOTEST_DOCKER_UID", os.getuid())) @@ -144,172 +137,37 @@ def _load_ver(t): return t.forge_version or "" if t.loader == "neoforge": return t.neoforge_version or "" + return "" -def _bridge_state(ctx): - return ctx["game_dir"] / "automodpack" / "autotest" / "bridge-state.json" - - -def _await(pred, timeout, msg): - dl = time.monotonic() + timeout - while time.monotonic() < dl: - r = pred() - if r is not None: - return r - _jitter_sleep(0.5) - raise TimeoutError(msg) - - -def _button_with_text(gui: dict, *needles: str, enabled: bool | None = None) -> dict | None: - lowered = tuple(n.lower() for n in needles) - candidates = [] - for button in gui.get("buttons", []): - if enabled is not None and bool(button.get("enabled", False)) != enabled: - continue - candidates.append(button) - if not lowered: - return candidates[0] if candidates else None - - for button in candidates: - text = str(button.get("text", "")).strip().lower() - if any(n == text for n in lowered): - return button - - for button in candidates: - text = str(button.get("text", "")).lower() - if not any(n in text for n in lowered): - continue - return button - return None - - -def _click_button(bridge: BridgeClient, *needles: str, enabled: bool | None = True) -> dict: - gui = bridge.gui() - button = _button_with_text(gui, *needles, enabled=enabled) - if button is None: - labels = [b.get("text", "") for b in gui.get("buttons", [])] - raise RuntimeError(f"No matching button {needles!r}; available={labels!r}") - return bridge.click(int(button["id"])) - - -def _first_text_field(gui: dict) -> dict | None: - fields = gui.get("textFields", []) - return fields[0] if fields else None - - -def run_case( - target: Target, - scenario: dict, - *, - out_dir: Path, - artifact_dir: Path, - client_image: str, - settings: dict, -) -> dict: - started = time.monotonic() - case_dir = out_dir / f"{target.id}-{int(time.time())}-{secrets.token_hex(3)}" - server_dir = case_dir / "server" - game_dir = case_dir / "client" / "game" - net_name = f"amp-{secrets.token_hex(4)}"[:63] - srv_name = f"amp-s-{secrets.token_hex(4)}"[:63] - cli_name = f"amp-c-{secrets.token_hex(4)}"[:63] - token = secrets.token_hex(16) - ctx = dict(locals()) +def _bridge_state(ctx: Context) -> Path: + return ctx.game_dir / "automodpack" / "autotest" / "bridge-state.json" - for d in (server_dir, game_dir): - d.mkdir(parents=True, exist_ok=True) - try: - pattern = f"automodpack-mc{target.minecraft}-{target.loader}-*.jar" - matches = sorted(artifact_dir.glob(pattern)) - if not matches: - raise FileNotFoundError(f"No artifact for {target.id} in {artifact_dir}") - ctx["artifact"] = matches[-1].resolve() +# ── server / client setup ───────────────────────────────────────────────── - sf = scenario.get("serverFiles", {}) - ctx["modpack_name"] = str(sf.get("modpackName", "amp-autotest")) - ctx["marker_rel"] = Path( - str(sf.get("marker", "config/amp-autotest-marker.json")) - ) - ctx["scenario_files"] = [ - (Path(str(f["path"])), str(f.get("content", ""))) - for f in sf.get("files", []) - ] - ctx["expected_mods"] = [str(m) for m in sf.get("expectedMods", [])] - _prepare_server(ctx, target, settings) - _ensure_network(net_name) - - flow = scenario.get("flow", []) - if not flow: - raise ValueError("scenario has no 'flow' list") - for phase_name in flow: - fn = PHASES.get(phase_name) - if not fn: - raise ValueError(f"unknown phase: {phase_name!r}") - logger.info("[%s] Phase: %s", target.id, phase_name) - fn(ctx) - - return { - "target": target.id, - "scenario": scenario.get("id", "?"), - "ok": True, - "duration": time.monotonic() - started, - } - - except Exception as e: - return { - "target": target.id, - "scenario": scenario.get("id", "?"), - "ok": False, - "duration": time.monotonic() - started, - "error": str(e), - } - - finally: - for name in [cli_name, srv_name]: - try: - logs = _container_logs(name) - if logs: - (case_dir / f"{name}.log").write_text( - logs, encoding="utf-8", errors="replace" - ) - except Exception: - pass - try: - _remove_container(name) - except Exception: - logger.warning("Failed to remove container %s", name) - _remove_network(net_name) - - -# === infrastructure (not flow phases) === - - -def _prepare_server(ctx, target, settings): - srv_dir = ctx["server_dir"] +def _prepare_server(ctx: Context): + srv_dir = ctx.server_dir (srv_dir / "mods").mkdir(parents=True, exist_ok=True) - shutil.copy2(ctx["artifact"], srv_dir / "mods" / "automodpack.jar") - cfg = dict(settings.get("automodpack", {}).get("config", {})) - cfg["modpackName"] = ctx["modpack_name"] - cfg["acceptedLoaders"] = [target.loader] + shutil.copy2(ctx.artifact, srv_dir / "mods" / "automodpack.jar") + cfg = dict(ctx.settings.get("automodpack", {}).get("config", {})) + cfg["modpackName"] = ctx.modpack_name + cfg["acceptedLoaders"] = [ctx.target.loader] (srv_dir / "automodpack").mkdir(parents=True, exist_ok=True) - (srv_dir / "automodpack" / "automodpack-server.json").write_text( - json.dumps(cfg, indent=2) - ) + (srv_dir / "automodpack" / "automodpack-server.json").write_text(json.dumps(cfg, indent=2)) host_root = srv_dir / "automodpack" / "host-modpack" / "main" host_root.mkdir(parents=True, exist_ok=True) - (host_root / ctx["marker_rel"]).parent.mkdir(parents=True, exist_ok=True) - (host_root / ctx["marker_rel"]).write_text( - json.dumps({"marker": ctx["modpack_name"]}) + "\n" - ) - for rel, content in ctx["scenario_files"]: + (host_root / ctx.marker_rel).parent.mkdir(parents=True, exist_ok=True) + (host_root / ctx.marker_rel).write_text(json.dumps({"marker": ctx.modpack_name}) + "\n") + for rel, content in ctx.scenario_files: f = host_root / rel f.parent.mkdir(parents=True, exist_ok=True) f.write_text(content) -def _launch_server(ctx, target, scenario, settings): +def _launch_server(ctx: Context): + target, scenario, settings = ctx.target, ctx.scenario, ctx.settings topo = scenario.get("topology", {}).get("server", {}) srv_type = topo.get("type") or settings.get("serverTypes", {}).get(target.loader) if not srv_type: @@ -337,10 +195,7 @@ def _launch_server(ctx, target, scenario, settings): str(p).strip() for p in ( list(mr.get("projects", [])) - + list( - (mr.get("projectsByLoader", {}) or {}).get(target.loader, []) - or [] - ) + + list((mr.get("projectsByLoader", {}) or {}).get(target.loader, []) or []) ) if p ) @@ -359,46 +214,43 @@ def _launch_server(ctx, target, scenario, settings): _ensure_volume(vol) mounts = [(vol, "/data", False)] for sub in ("mods", "automodpack"): - (ctx["server_dir"] / sub).mkdir(parents=True, exist_ok=True) - mounts.append((ctx["server_dir"] / sub, f"/data/{sub}", False)) + (ctx.server_dir / sub).mkdir(parents=True, exist_ok=True) + mounts.append((ctx.server_dir / sub, f"/data/{sub}", False)) else: - mounts = [(ctx["server_dir"], "/data", False)] - img = str( - topo.get("image") - or settings.get("images", {}).get("server", "itzg/minecraft-server") - ) + mounts = [(ctx.server_dir, "/data", False)] + img = str(topo.get("image") or settings.get("images", {}).get("server", "itzg/minecraft-server")) if ":" not in img: - img = f"{img}:{str(settings.get('images', {}).get('serverTagTemplate', 'java{java}')).format(java=target.java)}" - _run_container( - name=ctx["srv_name"], image=img, network=ctx["net_name"], env=env, mounts=mounts - ) + tag = str(settings.get("images", {}).get("serverTagTemplate", "java{java}")).format(java=target.java) + img = f"{img}:{tag}" + _run_container(name=ctx.srv_name, image=img, network=ctx.net_name, env=env, mounts=mounts) -def _launch_client(ctx, target, client_image): - game_dir = ctx["game_dir"] +def _launch_client(ctx: Context): + game_dir = ctx.game_dir (game_dir / "mods").mkdir(parents=True, exist_ok=True) - shutil.copy2(ctx["artifact"], game_dir / "mods" / "automodpack.jar") + shutil.copy2(ctx.artifact, game_dir / "mods" / "automodpack.jar") (game_dir / "options.txt").write_text("narrator:0\n") _bridge_state(ctx).unlink(missing_ok=True) # Per-target HMC cache (isolated to prevent concurrent NeoForge installer corruption) - hmc_cache_root = (ctx["out_dir"].parent / ".hmc-cache" / target.id.replace(".", "_")).resolve() + hmc_cache_root = (ctx.out_dir.parent / ".hmc-cache" / ctx.target.id.replace(".", "_")).resolve() hmc_cache_root.mkdir(parents=True, exist_ok=True) + client_run_seconds = int(float( + ctx.scenario.get("timeouts", {}).get( + "clientRunSeconds", + ctx.settings.get("timeouts", {}).get("clientRunSeconds", 600), + ) + )) _run_container( - name=ctx["cli_name"], - image=client_image, - network=ctx["net_name"], + name=ctx.cli_name, + image=ctx.client_image, + network=ctx.net_name, env={ - "AM_AUTOTEST_BRIDGE_TOKEN": ctx["token"], + "AM_AUTOTEST_BRIDGE_TOKEN": ctx.token, "AM_AUTOTEST_GAME_DIR": "/work/game", "AM_AUTOTEST_HMC_CACHE_DIR": "/work/hmc-cache", - "AM_AUTOTEST_CLIENT_TIMEOUT_SECONDS": str( - int(float(ctx["scenario"].get("timeouts", {}).get( - "clientRunSeconds", - ctx["settings"].get("timeouts", {}).get("clientRunSeconds", 600), - ))) - ), + "AM_AUTOTEST_CLIENT_TIMEOUT_SECONDS": str(client_run_seconds), }, mounts=[ (game_dir, "/work/game", False), @@ -406,306 +258,238 @@ def _launch_client(ctx, target, client_image): ], command=[ "/opt/automodpack/run-headlessmc-client", - target.loader, - target.minecraft, + ctx.target.loader, + ctx.target.minecraft, "localhost", "25565", - str(target.java), - _load_ver(target), + str(ctx.target.java), + _load_ver(ctx.target), ], user=f"{_uid()}:{_gid()}", ) _jitter_sleep(1) - _assert_running(ctx["cli_name"]) + _assert_running(ctx.cli_name) -def _wait_server(ctx, target, scenario, settings): - to = scenario.get("timeouts", {}) or settings.get("timeouts", {}) - _wait_for_log( - ctx["srv_name"], "Done (", timeout=float(to.get("serverStartSeconds", 180)) - ) +# ── lifecycle verbs (need Docker; pure UI/IO verbs live in engine/) ─────── + +@verb("launch_server") +def _v_launch_server(ctx: Context, step): + _launch_server(ctx) -# === flow phases === +@verb("wait_server") +def _v_wait_server(ctx: Context, step): + to = ctx.scenario.get("timeouts", {}) or ctx.settings.get("timeouts", {}) + timeout = parse_duration(step.get("timeout"), default=float(to.get("serverStartSeconds", 180))) + _wait_for_log(ctx.srv_name, "Done (", timeout=timeout) -@_reg("wait_bridge") -def _phase_wait_bridge(ctx): - if "bridge" in ctx: - return - ctx["bridge"] = BridgeClient(ctx["game_dir"], ctx["token"]) - to = float(ctx["scenario"].get("timeouts", {}).get("clientStartSeconds", 180)) - dl = time.monotonic() + to - while time.monotonic() < dl: + +@verb("launch_client", "relaunch_client") +def _v_launch_client(ctx: Context, step): + _remove_container(ctx.cli_name) + ctx.bridge = None + _launch_client(ctx) + + +@verb("wait_bridge") +def _v_wait_bridge(ctx: Context, step): + if ctx.bridge is None: + ctx.bridge = BridgeClient(ctx.game_dir, ctx.token) + timeout = parse_duration( + step.get("timeout"), + default=float(ctx.scenario.get("timeouts", {}).get("clientStartSeconds", 180)), + ) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: try: - _assert_running(ctx["cli_name"]) + _assert_running(ctx.cli_name) except RuntimeError as e: - logs = _container_logs(ctx["cli_name"]) - raise TimeoutError( - f"Client exited before bridge: {e}\n--- logs ---\n{logs[-2000:]}" - ) + logs = _container_logs(ctx.cli_name) + raise TimeoutError(f"Client exited before bridge: {e}\n--- logs ---\n{logs[-2000:]}") try: - state_file = _bridge_state(ctx) - if state_file.exists(): - data = json.loads(state_file.read_text(encoding="utf-8")) + sf = _bridge_state(ctx) + if sf.exists(): + data = json.loads(sf.read_text(encoding="utf-8")) if data.get("status") == "ready": - ctx["bridge"].request("ping", timeout=5) + ctx.bridge.request("ping", timeout=5) return except Exception: pass _jitter_sleep(1) - raise TimeoutError(f"Bridge for {ctx['target'].id} did not become available within {to}s") - - -@_reg("read_fingerprint") -def _phase_read_fingerprint(ctx): - to = float(ctx["scenario"].get("timeouts", {}).get("serverStartSeconds", 180)) - dl = time.monotonic() + to - while time.monotonic() < dl: - logs = _container_logs(ctx["srv_name"], tail=200) - for line in logs.splitlines(): - m = re.search( - r"(?:certificate\s+)?fingerprint[:\s]+([0-9A-Fa-f:]+)", line, re.IGNORECASE - ) - if m: - ctx["fingerprint"] = m.group(1) - return - _jitter_sleep(1) - raise RuntimeError("No TLS fingerprint found in server logs") - + raise TimeoutError(f"Bridge for {ctx.target.id} did not become available within {timeout}s") -@_reg("connect") -def _phase_connect(ctx): - bridge = ctx["bridge"] - host = ctx["srv_name"] - deadline = time.monotonic() + 90 +@verb("connect") +def _v_connect(ctx: Context, step): + host = ctx.resolve(step.get("host") or ctx.srv_name) + port = int(step.get("port", 25565)) + timeout = parse_duration(step.get("timeout"), default=90) + deadline = time.monotonic() + timeout _TITLE = ("TitleScreen", "class_442") _CONNECT = ("ConnectScreen", "class_397") - while time.monotonic() < deadline: - _assert_running(ctx["cli_name"]) - bridge.connect(host, 25565) - remaining = deadline - time.monotonic() - poll_dl = time.monotonic() + min(remaining, 45) + _assert_running(ctx.cli_name) + ctx.bridge.connect(host, port) + poll_dl = time.monotonic() + min(deadline - time.monotonic(), 45) while time.monotonic() < poll_dl: - screen = str(bridge.gui().get("screenClass") or "") + screen = str(ctx.bridge.gui().get("screenClass") or "") if any(n in screen for n in _TITLE): break if not any(n in screen for n in _CONNECT): return _jitter_sleep(0.5) - bridge.request("disconnect") + ctx.bridge.request("disconnect") _jitter_sleep(1) - raise RuntimeError("Could not connect after multiple attempts") - - -@_reg("wait_fingerprint") -def _phase_wait_fingerprint(ctx): - fp = ctx.get("fingerprint") - if not fp: - raise RuntimeError("No fingerprint — run read_fingerprint phase first") - _await( - lambda: ( - gui - if ( - (gui := ctx["bridge"].gui()) - and _first_text_field(gui) - and _button_with_text(gui, "verify", enabled=True) - ) - else None - ), - 180, - f"Certificate verification prompt did not appear for {ctx['target'].id} within 180s", - ) + raise RuntimeError(f"Could not connect to {host}:{port} after multiple attempts") -@_reg("accept_fingerprint") -def _phase_accept_fingerprint(ctx): - fp = ctx.get("fingerprint") - if not fp: - raise RuntimeError("No fingerprint — run read_fingerprint phase first") - bridge = ctx["bridge"] - gui = bridge.gui() - field = _first_text_field(gui) - if field is None: - raise RuntimeError("No fingerprint text field found") - bridge.text(int(field["id"]), fp) - _click_button(bridge, "verify") - - def _check(): - gui = bridge.gui() - if _button_with_text(gui, "verify"): - return None - return True - - _await(_check, 20, "Fingerprint verification did not complete") - - -@_reg("skip_fingerprint") -def _phase_skip_fingerprint(ctx): - bridge = ctx["bridge"] - _click_button(bridge, "skip") - _await( - lambda: ( - gui - if ((gui := bridge.gui()) and _first_text_field(gui) and _button_with_text(gui, "skip")) - else None - ), - 15, - "Skip screen not shown", - ) - field = _first_text_field(bridge.gui()) - if field is None: - raise RuntimeError("No skip confirmation text field found") - bridge.text(int(field["id"]), "I accept the risk") - dl = time.monotonic() + 30 - while time.monotonic() < dl: - gui = bridge.gui() - button = _button_with_text(gui, "skip", enabled=True) - if button: - bridge.click(int(button["id"])) - return - _jitter_sleep(1) - raise RuntimeError("Skip button did not activate") - - -@_reg("wait_download_prompt") -def _phase_wait_download_prompt(ctx): - bridge = ctx["bridge"] - _await( - lambda: ( - gui if ((gui := bridge.gui()) and _button_with_text(gui, "download", enabled=True)) else None - ), - 90, - "Download confirmation did not appear within 90s", - ) +@verb("disconnect") +def _v_disconnect(ctx: Context, step): + try: + if ctx.bridge is not None: + ctx.bridge.request("disconnect") + except (RuntimeError, TimeoutError): + pass + + +@verb("quit") +def _v_quit(ctx: Context, step): + try: + state = _inspect_container(ctx.cli_name).get("State", {}) + if state.get("Running", False) and ctx.bridge is not None: + ctx.bridge.request("quit") + except (RuntimeError, TimeoutError): + pass + +@verb("wait_client_exit") +def _v_wait_client_exit(ctx: Context, step): + timeout = parse_duration(step.get("timeout"), default=90) + _wait_exited(ctx.cli_name, timeout=timeout) -@_reg("confirm_download") -def _phase_confirm_download(ctx): - bridge = ctx["bridge"] - dl = time.monotonic() + 5 - while time.monotonic() < dl: - gui = bridge.gui() - if gui.get("buttons"): - break - _jitter_sleep(0.2) - button = _button_with_text(gui, "download", enabled=True) - if button is None: - raise RuntimeError("No active download confirmation button") - bridge.click(int(button["id"])) - - -@_reg("wait_download") -def _phase_wait_download(ctx): - marker = ( - ctx["game_dir"] - / "automodpack" - / "modpacks" - / ctx["modpack_name"] - / ctx["marker_rel"] + +@verb("wait_join") +def _v_wait_join(ctx: Context, step): + timeout = parse_duration( + step.get("timeout"), + default=float(ctx.scenario.get("timeouts", {}).get("rejoinSeconds", 180)), ) - timeout = float(ctx["scenario"].get("timeouts", {}).get("downloadFileSeconds", 300)) - _await( - lambda: marker if marker.exists() else None, + await_condition( + lambda: True if ctx.gui(timeout=10).get("screenClass") is None else None, timeout, - f"Download marker file {marker} did not appear within {timeout}s", + step.get("poll"), + f"{ctx.target.id}: player did not reach in-game", ) - if not marker.exists(): - raise FileNotFoundError(f"Missing marker: {marker}") -@_reg("verify_files") -def _phase_verify_files(ctx): - mp_root = ctx["game_dir"] / "automodpack" / "modpacks" / ctx["modpack_name"] - dl = time.monotonic() + 120 - while time.monotonic() < dl: - if all((mp_root / rel).exists() for rel, _ in ctx["scenario_files"]): - return - _jitter_sleep(2) - missing = [ - str(rel) for rel, _ in ctx["scenario_files"] if not (mp_root / rel).exists() - ] - raise TimeoutError(f"Files missing after sync: {', '.join(missing)}") - - -@_reg("verify_mods") -def _phase_verify_mods(ctx): - if not ctx["expected_mods"]: - return - mp_root = ctx["game_dir"] / "automodpack" / "modpacks" / ctx["modpack_name"] - dl = time.monotonic() + 120 - mod_dir = mp_root / "mods" - while time.monotonic() < dl: - mods = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() - if all(any(fnmatch(m, p) for m in mods) for p in ctx["expected_mods"]): - return - _jitter_sleep(2) - existing = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() - missing = [ - p for p in ctx["expected_mods"] if not any(fnmatch(m, p) for m in existing) - ] - raise TimeoutError(f"Mods missing after sync: {', '.join(missing)}") - - -@_reg("confirm_restart") -def _phase_confirm_restart(ctx): - bridge = ctx["bridge"] - dl = time.monotonic() + 20 - while time.monotonic() < dl: - try: - gui = bridge.gui() - except TimeoutError: - continue - button = _button_with_text(gui, "close the game", "restart", "quit", enabled=True) - if button: - bridge.click(int(button["id"])) - _wait_exited(ctx["cli_name"], timeout=90) - return - _jitter_sleep(0.5) +# ── case orchestration ──────────────────────────────────────────────────── -@_reg("quit") -def _phase_quit(ctx): +def run_case( + target: Target, + scenario: dict, + *, + out_dir: Path, + artifact_dir: Path, + client_image: str, + settings: dict, +) -> dict: + started = time.monotonic() + scenario_id = scenario.get("id", "?") + case_dir = out_dir / f"{target.id}-{int(time.time())}-{secrets.token_hex(3)}" + server_dir = case_dir / "server" + game_dir = case_dir / "client" / "game" + net_name = f"amp-{secrets.token_hex(4)}"[:63] + srv_name = f"amp-s-{secrets.token_hex(4)}"[:63] + cli_name = f"amp-c-{secrets.token_hex(4)}"[:63] + token = secrets.token_hex(16) + + for d in (server_dir, game_dir): + d.mkdir(parents=True, exist_ok=True) + + step_results: list[dict] = [] try: - state = _inspect_container(ctx["cli_name"]).get("State", {}) - if state.get("Running", False): - ctx["bridge"].request("quit") - except (RuntimeError, TimeoutError): - pass + pattern = f"automodpack-mc{target.minecraft}-{target.loader}-*.jar" + matches = sorted(artifact_dir.glob(pattern)) + if not matches: + raise FileNotFoundError(f"No artifact for {target.id} in {artifact_dir}") + artifact = matches[-1].resolve() + sf = scenario.get("serverFiles", {}) + modpack_name = str(sf.get("modpackName", "amp-autotest")) + marker_rel = Path(str(sf.get("marker", "config/amp-autotest-marker.json"))) + scenario_files = [ + (Path(str(f["path"])), str(f.get("content", ""))) for f in sf.get("files", []) + ] + expected_mods = [str(m) for m in sf.get("expectedMods", [])] + + ctx = Context( + target=target, + scenario=scenario, + settings=settings, + game_dir=game_dir, + server_dir=server_dir, + case_dir=case_dir, + out_dir=out_dir, + client_image=client_image, + srv_name=srv_name, + cli_name=cli_name, + net_name=net_name, + token=token, + artifact=artifact, + modpack_name=modpack_name, + marker_rel=marker_rel, + scenario_files=scenario_files, + expected_mods=expected_mods, + vars=dict(scenario.get("vars", {}) or {}), + ) + ctx.logs_provider = lambda which, tail=None: _container_logs( + srv_name if which == "server" else cli_name, tail=tail + ) -@_reg("launch_server") -def _phase_launch_server(ctx): - _launch_server(ctx, ctx["target"], ctx["scenario"], ctx["settings"]) + def _running(): + try: + _assert_running(cli_name) + except RuntimeError as e: + raise ClientExited(str(e)) + ctx.running_provider = _running -@_reg("wait_server") -def _phase_wait_server(ctx): - _wait_server(ctx, ctx["target"], ctx["scenario"], ctx["settings"]) + _prepare_server(ctx) + _ensure_network(net_name) + run_flow(ctx, scenario, lib=load_macros(), results=step_results) -@_reg("launch_client") -def _phase_launch_client(ctx): - _remove_container(ctx["cli_name"]) - if "bridge" in ctx: - del ctx["bridge"] - _launch_client(ctx, ctx["target"], ctx["client_image"]) + return { + "target": target.id, + "scenario": scenario_id, + "ok": True, + "duration": time.monotonic() - started, + "steps": step_results, + } + except Exception as e: + return { + "target": target.id, + "scenario": scenario_id, + "ok": False, + "duration": time.monotonic() - started, + "error": str(e), + "steps": step_results, + } -@_reg("wait_join") -def _phase_wait_join(ctx): - bridge = ctx["bridge"] - to = float(ctx["scenario"].get("timeouts", {}).get("rejoinSeconds", 180)) - dl = time.monotonic() + to - while time.monotonic() < dl: - _assert_running(ctx["cli_name"]) - try: - gui = bridge.gui(timeout=10) - if gui.get("screenClass") is None: - return - except (TimeoutError, RuntimeError): - pass - _jitter_sleep(2) - raise TimeoutError(f"{ctx['target'].id}: Player did not join in-game within {to}s") + finally: + for name in [cli_name, srv_name]: + try: + logs = _container_logs(name) + if logs: + (case_dir / f"{name}.log").write_text(logs, encoding="utf-8", errors="replace") + except Exception: + pass + try: + _remove_container(name) + except Exception: + logger.warning("Failed to remove container %s", name) + _remove_network(net_name) diff --git a/autotester/pyproject.toml b/autotester/pyproject.toml index 6b6dc4ea8..55b5cf95d 100644 --- a/autotester/pyproject.toml +++ b/autotester/pyproject.toml @@ -15,6 +15,13 @@ dependencies = [ [project.scripts] autotester = "automodpack_autotester.cli:main" +[dependency-groups] +dev = ["pytest>=8.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + [tool.setuptools.packages.find] where = ["."] include = ["automodpack_autotester*"] diff --git a/autotester/scenarios/_lib.yaml b/autotester/scenarios/_lib.yaml new file mode 100644 index 000000000..35c52d038 --- /dev/null +++ b/autotester/scenarios/_lib.yaml @@ -0,0 +1,92 @@ +# Shared, reusable step sequences (macros). Reference one from a scenario's +# `flow` by name (a bare string) or with `{ use: }`. Files starting with +# `_` are libraries, not scenarios, so the runner never executes this directly. +# +# Steps are declarative: every entry is either a bare verb/macro name, or a +# mapping with a `do:` verb plus its arguments. `${...}` templates expand against +# the scenario context (target, server, modpack, marker, and any captured vars). + +# Launch the server and client together (client boots while the server starts), +# capture the server's TLS fingerprint, then connect once both are ready. +boot: + - do: launch_server + name: start server + - do: launch_client + name: start client (boots in parallel with the server) + - use: read_server_fingerprint + - do: wait_server + name: wait for server to finish loading + - do: wait_bridge + name: wait for the client test bridge + - use: connect_to_server + +# Scrape the one-time certificate fingerprint out of the server log into a var. +read_server_fingerprint: + - do: wait_for + name: read TLS fingerprint from server log + timeout: 180s + until: + log: + container: server + matches: '(?:certificate\s+)?fingerprint[:\s]+([0-9A-Fa-f:]+)' + capture: { fingerprint: 1 } + +connect_to_server: + - do: connect + name: connect to the server + +# Enter the captured fingerprint on the trust prompt and verify it. +accept_certificate: + - do: wait_for + name: wait for the certificate prompt + timeout: 180s + until: + all: + - element: { role: textfield } + - element: { text: verify, enabled: true } + - do: type + name: enter the fingerprint + select: { role: textfield } + value: "${fingerprint}" + - do: click + name: click verify + select: { text: verify } + - do: wait_for + name: wait for the certificate to be accepted + timeout: 20s + until: + no_element: { text: verify } + +# Confirm the download prompt and wait for the modpack to land on disk. +download_modpack: + - do: wait_for + name: wait for the download prompt + timeout: 90s + until: + element: { text: download, enabled: true } + - do: click + name: confirm download + select: { text: download } + - do: wait_file + name: wait for the modpack marker file + path: "automodpack/modpacks/${modpack}/${marker}" + timeout: 300s + +# Click the post-download "restart required" button and wait for the client to exit. +restart_client: + - do: click + name: confirm restart + select: { text_any: ["close the game", "restart", "quit"] } + timeout: 20s + - do: wait_client_exit + name: wait for the client to close + +# Relaunch the client and play through to in-game (used to confirm a synced modpack loads). +rejoin: + - do: launch_client + name: relaunch the client + - do: wait_bridge + name: wait for the client test bridge + - use: connect_to_server + - do: wait_join + name: wait until the player is in-game diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml index 95142218a..fd0305444 100644 --- a/autotester/scenarios/download-only.yaml +++ b/autotester/scenarios/download-only.yaml @@ -1,21 +1,16 @@ id: download-only description: | - Launch server/client, accept fingerprint, sync modpack, verify files, and quit. + Boot server + client, trust the certificate, sync the modpack, and verify every + hosted file landed on the client. Does not restart or rejoin. flow: - - launch_server - - launch_client - - read_fingerprint - - wait_server - - wait_bridge - - connect - - wait_fingerprint - - accept_fingerprint - - wait_download_prompt - - confirm_download - - wait_download - - verify_files - - quit + - use: boot + - use: accept_certificate + - use: download_modpack + - do: verify_files + name: verify all synced files are present + - do: quit + name: quit the client topology: server: diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml index 456282ad8..ccc7ee583 100644 --- a/autotester/scenarios/sync.yaml +++ b/autotester/scenarios/sync.yaml @@ -1,28 +1,19 @@ id: sync description: | - Launch server/client, accept fingerprint, sync modpack, verify files, restart, - rejoin, and verify the player reaches the game. + Full round trip: boot, trust the certificate, sync the modpack, verify the files, + restart the client, rejoin, and confirm the player reaches the game with the + modpack applied. flow: - - launch_server - - launch_client - - read_fingerprint - - wait_server - - wait_bridge - - connect - - wait_fingerprint - - accept_fingerprint - - wait_download_prompt - - confirm_download - - wait_download - - verify_files - - confirm_restart - - quit - - launch_client - - wait_bridge - - connect - - wait_join - - quit + - use: boot + - use: accept_certificate + - use: download_modpack + - do: verify_files + name: verify all synced files are present + - use: restart_client + - use: rejoin + - do: quit + name: quit the client topology: server: diff --git a/autotester/tests/__init__.py b/autotester/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/autotester/tests/conftest.py b/autotester/tests/conftest.py new file mode 100644 index 000000000..2f3fa07e1 --- /dev/null +++ b/autotester/tests/conftest.py @@ -0,0 +1,132 @@ +"""Shared fixtures: a Docker-free Context factory and a scriptable fake bridge. + +These let the engine (selectors, conditions, templating, verbs, executor) be +exercised end to end without Docker, HeadlessMC, or a real Minecraft server. +""" +from __future__ import annotations + +import types +from pathlib import Path + +import pytest + +from automodpack_autotester.engine import Context + + +@pytest.fixture +def make_ctx(tmp_path): + """Build a Context backed by tmp dirs. Override any field via kwargs.""" + + def _make(**overrides) -> Context: + game_dir = overrides.pop("game_dir", tmp_path / "game") + game_dir.mkdir(parents=True, exist_ok=True) + defaults = dict( + target=types.SimpleNamespace( + id="1.21-fabric", minecraft="1.21", loader="fabric", java=21 + ), + scenario={}, + settings={}, + game_dir=game_dir, + server_dir=tmp_path / "server", + case_dir=tmp_path / "case", + out_dir=tmp_path / "out", + client_image="img", + srv_name="srv-container", + cli_name="cli-container", + net_name="net", + token="tok", + artifact=tmp_path / "automodpack.jar", + modpack_name="amp-autotest", + marker_rel=Path("config/amp-autotest-marker.json"), + scenario_files=[], + expected_mods=[], + ) + defaults.update(overrides) + ctx = Context(**defaults) + ctx.running_provider = lambda: None # client is always "running" in tests + return ctx + + return _make + + +class FakeBridge: + """A tiny GUI state machine that mimics the real client over the file bridge. + + Screens: title -> cert -> download -> restart -> (relaunch) -> ingame. + Clicking the download button writes the modpack files into the game dir, so + the filesystem verbs see real files appear exactly as they would in Docker. + """ + + def __init__(self, ctx: Context): + self.ctx = ctx + self.screen = "title" + self.fingerprint: str | None = None + self.synced = False + self.exited = False + self.clicks: list[int] = [] + self.typed: dict[int, str] = {} + + # --- snapshot --------------------------------------------------------- + def gui(self, timeout: float = 30) -> dict: + snapshots = { + "title": {"screenClass": "TitleScreen", "buttons": [], "textFields": []}, + "cert": { + "screenClass": "CertScreen", + "buttons": [{"id": 2, "text": "Verify", "enabled": True, "visible": True}], + "textFields": [{"id": 1, "text": "", "enabled": True, "visible": True}], + }, + "download": { + "screenClass": "DownloadScreen", + "buttons": [{"id": 3, "text": "Download", "enabled": True, "visible": True}], + "textFields": [], + }, + "restart": { + "screenClass": "RestartScreen", + "buttons": [{"id": 4, "text": "Close the game", "enabled": True, "visible": True}], + "textFields": [], + }, + "ingame": {"screenClass": None, "buttons": [], "textFields": []}, + } + return snapshots[self.screen] + + # --- actions ---------------------------------------------------------- + def text(self, element_id: int, value: str, timeout: float = 30) -> dict: + self.typed[element_id] = value + if element_id == 1: + self.fingerprint = value + return {"ok": True} + + def click(self, element_id: int, timeout: float = 30, **payload) -> dict: + self.clicks.append(element_id) + if element_id == 2 and self.fingerprint: + self.screen = "download" + elif element_id == 3: + self._write_modpack() + self.screen = "restart" + elif element_id == 4: + self.exited = True + return {"ok": True} + + def connect(self, host: str, port: int = 25565, timeout: float = 30) -> dict: + # Already-synced clients drop straight in-game; first contact hits the cert prompt. + self.screen = "ingame" if self.synced else "cert" + return {"ok": True} + + def request(self, op: str, timeout: float = 30, **payload) -> dict: + if op == "disconnect": + self.screen = "title" + elif op == "quit": + self.exited = True + return {"ok": True} + + # --- helpers ---------------------------------------------------------- + def _write_modpack(self) -> None: + root = self.ctx.game_dir / "automodpack" / "modpacks" / self.ctx.modpack_name + marker = root / self.ctx.marker_rel + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}") + for rel, content in self.ctx.scenario_files: + f = root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + self.synced = True diff --git a/autotester/tests/test_flows.py b/autotester/tests/test_flows.py new file mode 100644 index 000000000..507589061 --- /dev/null +++ b/autotester/tests/test_flows.py @@ -0,0 +1,145 @@ +"""End-to-end engine tests: run the *real* shipped scenarios and macro library +through a fake bridge, with the Docker lifecycle verbs stubbed out. + +This proves the declarative pipeline (config -> macros -> executor -> verbs -> +selectors/conditions/templating) works against the actual scenario files, with +no Docker, HeadlessMC, or Minecraft server involved. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from automodpack_autotester.config import load_macros, load_scenarios +from automodpack_autotester.engine import run_flow +from automodpack_autotester.engine.registry import verb + +from .conftest import FakeBridge + + +# ── stub the lifecycle verbs the runner normally provides (need Docker) ──── + + +@verb("launch_server", "wait_server") +def _noop(ctx, step): + pass + + +@verb("launch_client", "relaunch_client") +def _launch_client(ctx, step): + ctx.bridge.exited = False # a fresh client process is running + + +@verb("wait_bridge") +def _wait_bridge(ctx, step): + assert ctx.bridge is not None + + +@verb("connect") +def _connect(ctx, step): + ctx.bridge.connect(ctx.srv_name, int(step.get("port", 25565))) + + +@verb("quit") +def _quit(ctx, step): + ctx.bridge.request("quit") + + +@verb("disconnect") +def _disconnect(ctx, step): + ctx.bridge.request("disconnect") + + +@verb("wait_client_exit") +def _wait_client_exit(ctx, step): + assert ctx.bridge.exited, "client did not exit after restart" + + +@verb("wait_join") +def _wait_join(ctx, step): + assert ctx.gui().get("screenClass") is None, "player never reached in-game" + + +# ── helpers ─────────────────────────────────────────────────────────────── + + +def _ctx_for(make_ctx, scenario: dict): + sf = scenario.get("serverFiles", {}) + files = [(Path(str(f["path"])), str(f.get("content", ""))) for f in sf.get("files", [])] + ctx = make_ctx( + scenario=scenario, + modpack_name=str(sf.get("modpackName", "amp-autotest")), + marker_rel=Path(str(sf.get("marker", "config/amp-autotest-marker.json"))), + scenario_files=files, + expected_mods=[str(m) for m in sf.get("expectedMods", [])], + ) + ctx.bridge = FakeBridge(ctx) + ctx.logs_provider = lambda which, tail=None: ( + "[Server thread/INFO]: Certificate fingerprint: AB:CD:EF:01:23" + if which == "server" + else "" + ) + return ctx + + +# ── tests ───────────────────────────────────────────────────────────────── + + +def test_download_only_flow(make_ctx): + scenario = load_scenarios()["download-only"] + ctx = _ctx_for(make_ctx, scenario) + + results = run_flow(ctx, scenario, lib=load_macros()) + + assert all(r["ok"] for r in results), [r for r in results if not r["ok"]] + assert ctx.bridge.fingerprint == "AB:CD:EF:01:23" + root = ctx.game_dir / "automodpack" / "modpacks" / ctx.modpack_name + for rel, _ in ctx.scenario_files: + assert (root / rel).exists(), f"missing synced file {rel}" + + +def test_sync_flow_round_trip(make_ctx): + scenario = load_scenarios()["sync"] + ctx = _ctx_for(make_ctx, scenario) + + results = run_flow(ctx, scenario, lib=load_macros()) + + assert all(r["ok"] for r in results), [r for r in results if not r["ok"]] + names = [r["name"] for r in results] + # the round trip really restarted and rejoined + assert any("relaunch" in n for n in names), names + assert any("in-game" in n for n in names), names + assert ctx.bridge.exited # final quit + + +def test_scenarios_only_reference_known_verbs(): + """Static guard: every verb named in the shipped scenarios/macros exists.""" + from automodpack_autotester.engine.registry import VERBS + + macros = load_macros() + scenarios = load_scenarios() + + def verbs_in(steps): + for raw in steps: + if isinstance(raw, str): + if raw not in macros: + yield raw + elif isinstance(raw, dict): + if "do" in raw: + yield raw["do"] + for key in ("steps",): + if isinstance(raw.get(key), list): + yield from verbs_in(raw[key]) + + used: set[str] = set() + for seq in macros.values(): + used.update(verbs_in(seq)) + for sc in scenarios.values(): + used.update(verbs_in(sc.get("flow", []))) + for name in sc.get("flow", []): + if isinstance(name, dict) and "use" in name: + used.update(verbs_in(macros.get(name["use"], []))) + + unknown = {v for v in used if v not in VERBS} + assert not unknown, f"scenarios reference unregistered verbs: {unknown}" diff --git a/autotester/tests/test_unit.py b/autotester/tests/test_unit.py new file mode 100644 index 000000000..69641f6a8 --- /dev/null +++ b/autotester/tests/test_unit.py @@ -0,0 +1,245 @@ +"""Unit tests for the declarative engine: parsing, selectors, conditions, +templating, polling, and the flow executor — all Docker-free.""" +from __future__ import annotations + +import time + +import pytest + +from automodpack_autotester.engine import conditions, run_flow, selectors +from automodpack_autotester.engine.registry import verb +from automodpack_autotester.engine.util import ClientExited, await_condition, parse_duration + + +# ── parse_duration ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "value,expected", + [ + ("90s", 90.0), + ("3m", 180.0), + ("500ms", 0.5), + ("2h", 7200.0), + ("180", 180.0), + (45, 45.0), + (1.5, 1.5), + ], +) +def test_parse_duration_values(value, expected): + assert parse_duration(value) == expected + + +def test_parse_duration_default_and_invalid(): + assert parse_duration(None, default=12) == 12.0 + assert parse_duration("garbage", default=7) == 7.0 + assert parse_duration(None) is None + + +# ── await_condition ─────────────────────────────────────────────────────── + + +def test_await_condition_returns_first_non_none(): + seen = [] + + def pred(): + seen.append(1) + return "done" if len(seen) >= 3 else None + + assert await_condition(pred, timeout=5, poll="1ms") == "done" + assert len(seen) >= 3 + + +def test_await_condition_reraises_client_exited(): + def pred(): + raise ClientExited("client gone") + + with pytest.raises(ClientExited): + await_condition(pred, timeout=5, poll="1ms") + + +def test_await_condition_swallows_transient_then_times_out(): + def pred(): + raise RuntimeError("bridge hiccup") + + start = time.monotonic() + with pytest.raises(TimeoutError) as e: + await_condition(pred, timeout=0.2, poll="10ms", msg="never") + assert "bridge hiccup" in str(e.value) + assert time.monotonic() - start < 2 + + +# ── selectors ───────────────────────────────────────────────────────────── + + +GUI = { + "screenClass": "S", + "buttons": [ + {"id": 1, "text": "Cancel", "enabled": True, "class": "net.Btn"}, + {"id": 2, "text": "Download file", "enabled": False, "class": "net.Btn"}, + {"id": 3, "text": "Download", "enabled": True, "class": "net.Btn"}, + ], + "textFields": [{"id": 9, "text": "", "enabled": True, "class": "net.Edit"}], +} + + +def test_selector_exact_preferred_over_substring(): + el = selectors.find_one(GUI, {"text": "Download"}) + assert el["id"] == 3 # exact "Download", not "Download file" + + +def test_selector_enabled_filter(): + el = selectors.find_one(GUI, {"text_any": ["download file"], "enabled": True}) + assert el is None # the only "download file" button is disabled + + +def test_selector_role_and_class(): + assert selectors.find_one(GUI, {"role": "textfield"})["id"] == 9 + assert selectors.find_one(GUI, {"class": "edit"})["id"] == 9 + + +def test_selector_index_negative(): + btns = selectors.find_all(GUI, {"role": "button"}) + assert len(btns) == 3 + assert selectors.find_one(GUI, {"role": "button", "index": -1})["id"] == 3 + + +def test_selector_no_match(): + assert selectors.find_one(GUI, {"text": "nope"}) is None + + +# ── templating ──────────────────────────────────────────────────────────── + + +def test_resolve_builtins_and_vars(make_ctx): + ctx = make_ctx(vars={"who": "world"}) + assert ctx.resolve("${target.id}") == "1.21-fabric" + assert ctx.resolve("${server.host}:${server.port}") == "srv-container:25565" + assert ctx.resolve("${modpack}") == "amp-autotest" + assert ctx.resolve("${marker}") == "config/amp-autotest-marker.json" + assert ctx.resolve("hello ${who}") == "hello world" + + +def test_resolve_nested_structures(make_ctx): + ctx = make_ctx(vars={"x": "1"}) + out = ctx.resolve({"a": ["${x}", "b"], "c": {"d": "${modpack}"}}) + assert out == {"a": ["1", "b"], "c": {"d": "amp-autotest"}} + + +def test_resolve_unknown_var_raises(make_ctx): + ctx = make_ctx() + with pytest.raises(KeyError): + ctx.resolve("${nope}") + + +# ── conditions ──────────────────────────────────────────────────────────── + + +def test_condition_screen_and_element(make_ctx): + ctx = make_ctx() + assert conditions.evaluate(ctx, {"screen": "DownloadScreen"}, gui=GUI) is False + assert conditions.evaluate(ctx, {"screen": "S"}, gui=GUI) is True + assert conditions.evaluate(ctx, {"element": {"text": "Download"}}, gui=GUI) is True + assert conditions.evaluate(ctx, {"no_element": {"text": "missing"}}, gui=GUI) is True + + +def test_condition_screen_none(make_ctx): + ctx = make_ctx() + assert conditions.evaluate(ctx, {"screen_none": True}, gui={"screenClass": None}) is True + assert conditions.evaluate(ctx, {"screen_none": True}, gui=GUI) is False + + +def test_condition_file_and_gone(make_ctx): + ctx = make_ctx() + (ctx.game_dir / "here.txt").write_text("x") + assert conditions.evaluate(ctx, {"file": "here.txt"}) is True + assert conditions.evaluate(ctx, {"file_gone": "nope.txt"}) is True + assert conditions.evaluate(ctx, {"file": "nope.txt"}) is False + + +def test_condition_all_any_not(make_ctx): + ctx = make_ctx() + cond = {"all": [{"screen": "S"}, {"not": {"screen": "X"}}]} + assert conditions.evaluate(ctx, cond, gui=GUI) is True + assert conditions.evaluate(ctx, {"any": [{"screen": "X"}, {"screen": "S"}]}, gui=GUI) is True + + +def test_condition_log_captures_variable(make_ctx): + ctx = make_ctx() + ctx.logs_provider = lambda which, tail=None: "line\nCertificate fingerprint: AB:CD:EF\nmore" + cond = {"log": {"container": "server", "matches": r"fingerprint[:\s]+([0-9A-Fa-f:]+)", + "capture": {"fp": 1}}} + assert conditions.evaluate(ctx, cond) is True + assert ctx.vars["fp"] == "AB:CD:EF" + + +# ── executor ────────────────────────────────────────────────────────────── + + +@verb("t_rec") +def _t_rec(ctx, step): + ctx.vars.setdefault("log", []).append(step.get("tag", "?")) + + +@verb("t_boom") +def _t_boom(ctx, step): + raise RuntimeError("kaboom") + + +def test_executor_macro_and_group_expansion(make_ctx): + ctx = make_ctx() + lib = {"greet": [{"do": "t_rec", "tag": "a"}, {"do": "t_rec", "tag": "b"}]} + scenario = { + "flow": [ + "greet", + {"group": True, "steps": [{"do": "t_rec", "tag": "c"}]}, + {"use": "greet"}, + ] + } + run_flow(ctx, scenario, lib=lib) + assert ctx.vars["log"] == ["a", "b", "c", "a", "b"] + + +def test_executor_when_gate_and_repeat(make_ctx): + ctx = make_ctx() + scenario = { + "flow": [ + {"do": "t_rec", "tag": "x", "repeat": 3}, + {"do": "t_rec", "tag": "skipped", "when": {"file": "absent.txt"}}, + ] + } + run_flow(ctx, scenario) + assert ctx.vars["log"] == ["x", "x", "x"] + + +def test_executor_records_results_and_optional(make_ctx): + ctx = make_ctx() + results = run_flow(ctx, {"flow": [{"do": "t_boom", "name": "explode", "optional": True}]}) + assert results[0]["ok"] is False + assert "kaboom" in results[0]["error"] + + +def test_executor_propagates_failure_with_partial_results(make_ctx): + ctx = make_ctx() + collected: list = [] + with pytest.raises(RuntimeError) as e: + run_flow( + ctx, + {"flow": [{"do": "t_rec", "tag": "ok"}, {"do": "t_boom", "name": "bad"}]}, + results=collected, + ) + assert "step 'bad' failed" in str(e.value) + assert [r["name"] for r in collected] == ["t_rec", "bad"] + assert collected[-1]["ok"] is False + + +def test_executor_unknown_verb(make_ctx): + ctx = make_ctx() + with pytest.raises(ValueError): + run_flow(ctx, {"flow": [{"do": "does_not_exist"}]}) + + +def test_executor_requires_flow(make_ctx): + ctx = make_ctx() + with pytest.raises(ValueError): + run_flow(ctx, {}) diff --git a/autotester/uv.lock b/autotester/uv.lock index 5bd42de43..687955512 100644 --- a/autotester/uv.lock +++ b/autotester/uv.lock @@ -11,12 +11,20 @@ dependencies = [ { name = "pyyaml" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "docker", specifier = ">=7.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "certifi" version = "2026.5.20" @@ -115,6 +123,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "docker" version = "7.1.0" @@ -138,6 +155,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "pywin32" version = "311" From 4c17715e510cbb15c40c56b96cba4d0a2df5576e Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 27 Jun 2026 13:44:33 +0200 Subject: [PATCH 43/44] =?UTF-8?q?autotester:=20simplify=20pass=20=E2=80=94?= =?UTF-8?q?=20dedup=20verbs,=20fix=20when/repeat=20on=20macros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executor: `when`/`repeat` now apply to `use`/`group` steps too (were silently ignored); normalize a bare-string step once and gate/repeat uniformly. - config: add `parse_server_files()` (shared by runner + tests, removes the triplicated serverFiles schema + default constants); memoize `load_macros()`. - engine: drop dead `Context.case_dir`; add `${modpack_dir}` template var to replace the repeated `automodpack/modpacks/${modpack}` literal. - steps_ui/steps_io: extract `_await_element` / `_await_exist` to remove the duplicated resolve-selector and wait-for-paths boilerplate. - remove unused `relaunch_client` verb alias and `engine.get` re-export. - tests: lock in when/repeat-on-macros behavior; reuse parse_server_files. 34 unit/flow tests pass; 1.21.1-fabric sync e2e still green. --- autotester/README.md | 2 +- autotester/automodpack_autotester/config.py | 29 +++++++++++++- .../automodpack_autotester/engine/__init__.py | 4 +- .../automodpack_autotester/engine/context.py | 2 +- .../automodpack_autotester/engine/executor.py | 33 +++++++-------- .../automodpack_autotester/engine/steps_io.py | 40 ++++++++----------- .../automodpack_autotester/engine/steps_ui.py | 26 ++++++------ autotester/automodpack_autotester/runner.py | 21 ++++------ autotester/scenarios/_lib.yaml | 2 +- autotester/tests/conftest.py | 1 - autotester/tests/test_flows.py | 17 ++++---- autotester/tests/test_unit.py | 14 +++++++ 12 files changed, 106 insertions(+), 85 deletions(-) diff --git a/autotester/README.md b/autotester/README.md index 16f6cf6b2..a08f27b7c 100644 --- a/autotester/README.md +++ b/autotester/README.md @@ -153,7 +153,7 @@ A step is either a bare name (`- quit`, or a macro name) or a mapping with a | `verify_files` | Wait until every `serverFiles.files` entry exists in the synced modpack. | | `verify_mods` | Wait until every `serverFiles.expectedMods` glob is present. | | `launch_server` / `wait_server` | Start the server / wait for `Done (`. | -| `launch_client` / `relaunch_client` / `wait_bridge` | Start a client / wait for its bridge. | +| `launch_client` / `wait_bridge` | Start a client / wait for its bridge. | | `connect` / `disconnect` / `quit` | Drive the client connection. | | `wait_client_exit` | Wait for the client container to exit (after a restart). | | `wait_join` | Wait until the player is in-game (no screen open). | diff --git a/autotester/automodpack_autotester/config.py b/autotester/automodpack_autotester/config.py index 7b505eb82..8a2b3a475 100644 --- a/autotester/automodpack_autotester/config.py +++ b/autotester/automodpack_autotester/config.py @@ -1,6 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from functools import lru_cache from pathlib import Path import yaml @@ -67,7 +68,31 @@ def load_scenarios() -> dict[str, dict]: } +@lru_cache(maxsize=1) def load_macros() -> dict: - """Shared reusable step sequences from ``scenarios/_lib.yaml`` (if present).""" + """Shared reusable step sequences from ``scenarios/_lib.yaml`` (if present). + + Cached: the library is static for a run and read once per process. + """ lib = ROOT / "scenarios" / "_lib.yaml" return load_yaml(lib) if lib.is_file() else {} + + +@dataclass(frozen=True) +class ServerFiles: + """The modpack a scenario hosts on the server, parsed from ``serverFiles``.""" + + modpack_name: str + marker: Path + files: list[tuple[Path, str]] = field(default_factory=list) + expected_mods: list[str] = field(default_factory=list) + + +def parse_server_files(scenario: dict) -> ServerFiles: + sf = scenario.get("serverFiles", {}) or {} + return ServerFiles( + modpack_name=str(sf.get("modpackName", "amp-autotest")), + marker=Path(str(sf.get("marker", "config/amp-autotest-marker.json"))), + files=[(Path(str(f["path"])), str(f.get("content", ""))) for f in sf.get("files", [])], + expected_mods=[str(m) for m in sf.get("expectedMods", [])], + ) diff --git a/autotester/automodpack_autotester/engine/__init__.py b/autotester/automodpack_autotester/engine/__init__.py index 1d5f0504f..390a80a33 100644 --- a/autotester/automodpack_autotester/engine/__init__.py +++ b/autotester/automodpack_autotester/engine/__init__.py @@ -6,6 +6,6 @@ from . import steps_io, steps_ui # noqa: F401 -- import for verb registration from .context import ClientExited, Context from .executor import run_flow -from .registry import get, verb +from .registry import verb -__all__ = ["Context", "ClientExited", "run_flow", "verb", "get"] +__all__ = ["Context", "ClientExited", "run_flow", "verb"] diff --git a/autotester/automodpack_autotester/engine/context.py b/autotester/automodpack_autotester/engine/context.py index 5b4bb52cf..800879c77 100644 --- a/autotester/automodpack_autotester/engine/context.py +++ b/autotester/automodpack_autotester/engine/context.py @@ -22,7 +22,6 @@ class Context: settings: dict game_dir: Path server_dir: Path - case_dir: Path out_dir: Path client_image: str srv_name: str @@ -48,6 +47,7 @@ def namespace(self) -> dict: "server": {"host": self.srv_name, "port": 25565}, "client": {"game_dir": str(self.game_dir)}, "modpack": self.modpack_name, + "modpack_dir": f"automodpack/modpacks/{self.modpack_name}", "marker": str(self.marker_rel), **self.vars, } diff --git a/autotester/automodpack_autotester/engine/executor.py b/autotester/automodpack_autotester/engine/executor.py index 67f5948b4..c682dcdf6 100644 --- a/autotester/automodpack_autotester/engine/executor.py +++ b/autotester/automodpack_autotester/engine/executor.py @@ -31,33 +31,30 @@ def _run_steps(ctx, steps, macros, results, depth=0): if depth > 25: raise RuntimeError("macro expansion too deep (cycle in sequences?)") for raw in steps: + # Normalize to a mapping; a bare string is a macro name or a verb name. if isinstance(raw, str): - if raw in macros: - _run_steps(ctx, macros[raw], macros, results, depth + 1) - continue - step = {"do": raw} + step = {"use": raw} if raw in macros else {"do": raw} elif isinstance(raw, dict): step = dict(raw) else: raise ValueError(f"invalid step: {raw!r}") - if "use" in step: - name = step["use"] - if name not in macros: - raise ValueError(f"unknown macro: {name!r}") - _run_steps(ctx, macros[name], macros, results, depth + 1) - continue - if "group" in step: - _run_steps(ctx, step.get("steps", []), macros, results, depth + 1) - continue - + # `when` and `repeat` apply uniformly to every step kind (verb, use, group). when = step.get("when") if when is not None and not conditions.evaluate(ctx, ctx.resolve(when)): - logger.info("[%s] skip (when not met): %s", _tid(ctx), step.get("name") or step.get("do")) + logger.info("[%s] skip (when not met): %s", _tid(ctx), _label(step)) continue for _ in range(int(step.get("repeat", 1))): - _run_one(ctx, step, results) + if "use" in step: + name = step["use"] + if name not in macros: + raise ValueError(f"unknown macro: {name!r}") + _run_steps(ctx, macros[name], macros, results, depth + 1) + elif "group" in step: + _run_steps(ctx, step.get("steps", []), macros, results, depth + 1) + else: + _run_one(ctx, step, results) def _run_one(ctx, step, results): @@ -87,3 +84,7 @@ def _run_one(ctx, step, results): def _tid(ctx): return getattr(ctx.target, "id", "?") + + +def _label(step): + return step.get("name") or step.get("do") or step.get("use") or "?" diff --git a/autotester/automodpack_autotester/engine/steps_io.py b/autotester/automodpack_autotester/engine/steps_io.py index 11abcb7c5..65ecbf8ed 100644 --- a/autotester/automodpack_autotester/engine/steps_io.py +++ b/autotester/automodpack_autotester/engine/steps_io.py @@ -11,50 +11,44 @@ from .util import await_condition, parse_duration -@verb("wait_file") -def wait_file(ctx, step): - rel = ctx.resolve(str(step["path"])) - p = ctx.game_dir / rel - timeout = parse_duration(step.get("timeout"), default=300) +def _await_exist(ctx, root, rels, step, msg, default_timeout): + """Poll until every path in ``rels`` exists under ``root``, or time out.""" + paths = [root / r for r in rels] + timeout = parse_duration(step.get("timeout"), default=default_timeout) await_condition( - lambda: p if p.exists() else None, + lambda: True if all(p.exists() for p in paths) else None, timeout, step.get("poll"), - f"file {rel} did not appear", + msg, ) +@verb("wait_file") +def wait_file(ctx, step): + rel = ctx.resolve(str(step["path"])) + _await_exist(ctx, ctx.game_dir, [rel], step, f"file {rel} did not appear", 300) + + @verb("wait_files") def wait_files(ctx, step): root = ctx.game_dir / ctx.resolve(str(step.get("root", ""))) - paths = [ctx.resolve(str(p)) for p in step.get("paths", [])] - timeout = parse_duration(step.get("timeout"), default=120) - - def _all(): - return True if all((root / p).exists() for p in paths) else None - - missing_msg = f"files did not all appear under {root}" - await_condition(_all, timeout, step.get("poll"), missing_msg) + rels = [ctx.resolve(str(p)) for p in step.get("paths", [])] + _await_exist(ctx, root, rels, step, f"files did not all appear under {root}", 120) @verb("verify_files") def verify_files(ctx, step): """Wait until every file declared in the scenario's ``serverFiles`` is present.""" - root = ctx.game_dir / ctx.resolve(str(step.get("root", "automodpack/modpacks/${modpack}"))) + root = ctx.game_dir / ctx.resolve(str(step.get("root", "${modpack_dir}"))) rels = [str(rel) for rel, _ in ctx.scenario_files] - timeout = parse_duration(step.get("timeout"), default=120) - - def _all(): - return True if all((root / r).exists() for r in rels) else None - - await_condition(_all, timeout, step.get("poll"), f"modpack files missing under {root}") + _await_exist(ctx, root, rels, step, f"modpack files missing under {root}", 120) @verb("verify_mods") def verify_mods(ctx, step): if not ctx.expected_mods: return - mod_dir = ctx.game_dir / ctx.resolve(str(step.get("root", "automodpack/modpacks/${modpack}/mods"))) + mod_dir = ctx.game_dir / ctx.resolve(str(step.get("root", "${modpack_dir}/mods"))) timeout = parse_duration(step.get("timeout"), default=120) def _all(): diff --git a/autotester/automodpack_autotester/engine/steps_ui.py b/autotester/automodpack_autotester/engine/steps_ui.py index fadcef997..8f70d922a 100644 --- a/autotester/automodpack_autotester/engine/steps_ui.py +++ b/autotester/automodpack_autotester/engine/steps_ui.py @@ -8,18 +8,22 @@ from .util import await_condition, parse_duration -@verb("click") -def click(ctx, step): - selector = dict(ctx.resolve(step.get("select") or {})) - if "enabled" not in selector: - selector["enabled"] = True # by default only click clickable elements +def _await_element(ctx, selector, step, not_found): + """Poll the GUI until ``selector`` matches an element, or time out.""" timeout = parse_duration(step.get("timeout"), default=30) - el = await_condition( + return await_condition( lambda: selectors.find_one(ctx.gui(), selector), timeout, step.get("poll"), - f"no element matched {selector!r}", + not_found, ) + + +@verb("click") +def click(ctx, step): + selector = dict(ctx.resolve(step.get("select") or {})) + selector.setdefault("enabled", True) # by default only click clickable elements + el = _await_element(ctx, selector, step, f"no element matched {selector!r}") if step.get("enable"): ctx.bridge.click(int(el["id"]), enable=True) else: @@ -30,13 +34,7 @@ def click(ctx, step): def type_(ctx, step): selector = dict(ctx.resolve(step.get("select") or {"role": "textfield"})) value = str(ctx.resolve(step.get("value", ""))) - timeout = parse_duration(step.get("timeout"), default=30) - el = await_condition( - lambda: selectors.find_one(ctx.gui(), selector), - timeout, - step.get("poll"), - f"no text field matched {selector!r}", - ) + el = _await_element(ctx, selector, step, f"no text field matched {selector!r}") ctx.bridge.text(int(el["id"]), value) diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py index b3f911871..923858d64 100644 --- a/autotester/automodpack_autotester/runner.py +++ b/autotester/automodpack_autotester/runner.py @@ -12,7 +12,7 @@ import docker as docker_py from .bridge import BridgeClient -from .config import Target, load_macros +from .config import Target, load_macros, parse_server_files from .engine import ClientExited, Context, run_flow from .engine.registry import verb from .engine.util import await_condition, parse_duration @@ -286,7 +286,7 @@ def _v_wait_server(ctx: Context, step): _wait_for_log(ctx.srv_name, "Done (", timeout=timeout) -@verb("launch_client", "relaunch_client") +@verb("launch_client") def _v_launch_client(ctx: Context, step): _remove_container(ctx.cli_name) ctx.bridge = None @@ -417,13 +417,7 @@ def run_case( raise FileNotFoundError(f"No artifact for {target.id} in {artifact_dir}") artifact = matches[-1].resolve() - sf = scenario.get("serverFiles", {}) - modpack_name = str(sf.get("modpackName", "amp-autotest")) - marker_rel = Path(str(sf.get("marker", "config/amp-autotest-marker.json"))) - scenario_files = [ - (Path(str(f["path"])), str(f.get("content", ""))) for f in sf.get("files", []) - ] - expected_mods = [str(m) for m in sf.get("expectedMods", [])] + sf = parse_server_files(scenario) ctx = Context( target=target, @@ -431,7 +425,6 @@ def run_case( settings=settings, game_dir=game_dir, server_dir=server_dir, - case_dir=case_dir, out_dir=out_dir, client_image=client_image, srv_name=srv_name, @@ -439,10 +432,10 @@ def run_case( net_name=net_name, token=token, artifact=artifact, - modpack_name=modpack_name, - marker_rel=marker_rel, - scenario_files=scenario_files, - expected_mods=expected_mods, + modpack_name=sf.modpack_name, + marker_rel=sf.marker, + scenario_files=sf.files, + expected_mods=sf.expected_mods, vars=dict(scenario.get("vars", {}) or {}), ) ctx.logs_provider = lambda which, tail=None: _container_logs( diff --git a/autotester/scenarios/_lib.yaml b/autotester/scenarios/_lib.yaml index 35c52d038..684d450e3 100644 --- a/autotester/scenarios/_lib.yaml +++ b/autotester/scenarios/_lib.yaml @@ -69,7 +69,7 @@ download_modpack: select: { text: download } - do: wait_file name: wait for the modpack marker file - path: "automodpack/modpacks/${modpack}/${marker}" + path: "${modpack_dir}/${marker}" timeout: 300s # Click the post-download "restart required" button and wait for the client to exit. diff --git a/autotester/tests/conftest.py b/autotester/tests/conftest.py index 2f3fa07e1..36b725255 100644 --- a/autotester/tests/conftest.py +++ b/autotester/tests/conftest.py @@ -28,7 +28,6 @@ def _make(**overrides) -> Context: settings={}, game_dir=game_dir, server_dir=tmp_path / "server", - case_dir=tmp_path / "case", out_dir=tmp_path / "out", client_image="img", srv_name="srv-container", diff --git a/autotester/tests/test_flows.py b/autotester/tests/test_flows.py index 507589061..ea8583bd9 100644 --- a/autotester/tests/test_flows.py +++ b/autotester/tests/test_flows.py @@ -7,11 +7,9 @@ """ from __future__ import annotations -from pathlib import Path - import pytest -from automodpack_autotester.config import load_macros, load_scenarios +from automodpack_autotester.config import load_macros, load_scenarios, parse_server_files from automodpack_autotester.engine import run_flow from automodpack_autotester.engine.registry import verb @@ -26,7 +24,7 @@ def _noop(ctx, step): pass -@verb("launch_client", "relaunch_client") +@verb("launch_client") def _launch_client(ctx, step): ctx.bridge.exited = False # a fresh client process is running @@ -65,14 +63,13 @@ def _wait_join(ctx, step): def _ctx_for(make_ctx, scenario: dict): - sf = scenario.get("serverFiles", {}) - files = [(Path(str(f["path"])), str(f.get("content", ""))) for f in sf.get("files", [])] + sf = parse_server_files(scenario) ctx = make_ctx( scenario=scenario, - modpack_name=str(sf.get("modpackName", "amp-autotest")), - marker_rel=Path(str(sf.get("marker", "config/amp-autotest-marker.json"))), - scenario_files=files, - expected_mods=[str(m) for m in sf.get("expectedMods", [])], + modpack_name=sf.modpack_name, + marker_rel=sf.marker, + scenario_files=sf.files, + expected_mods=sf.expected_mods, ) ctx.bridge = FakeBridge(ctx) ctx.logs_provider = lambda which, tail=None: ( diff --git a/autotester/tests/test_unit.py b/autotester/tests/test_unit.py index 69641f6a8..ae0b9340f 100644 --- a/autotester/tests/test_unit.py +++ b/autotester/tests/test_unit.py @@ -212,6 +212,20 @@ def test_executor_when_gate_and_repeat(make_ctx): assert ctx.vars["log"] == ["x", "x", "x"] +def test_executor_when_and_repeat_apply_to_macros_and_groups(make_ctx): + ctx = make_ctx() + lib = {"greet": [{"do": "t_rec", "tag": "g"}]} + scenario = { + "flow": [ + {"use": "greet", "when": {"file": "absent.txt"}}, # gated out + {"use": "greet", "repeat": 2}, # macro runs twice + {"group": True, "steps": [{"do": "t_rec", "tag": "x"}], "repeat": 2}, + ] + } + run_flow(ctx, scenario, lib=lib) + assert ctx.vars["log"] == ["g", "g", "x", "x"] + + def test_executor_records_results_and_optional(make_ctx): ctx = make_ctx() results = run_flow(ctx, {"flow": [{"do": "t_boom", "name": "explode", "optional": True}]}) From 00be8a08559cbcedf4c86964043770685c31b683 Mon Sep 17 00:00:00 2001 From: skidam Date: Sat, 27 Jun 2026 13:47:18 +0200 Subject: [PATCH 44/44] ci: run autotester engine tests (Docker-free) in CI Add reusable step.autotester-tests workflow that runs the new pytest engine suite, and wire it into: - gradle.yml (Dev Builds) so every push validates the autotester engine - ingame-tests.yml as a fast-fail gate before the build + Docker matrix No change to release.yml (releases still never pass -Pautomodpack.autotest). --- .github/workflows/autotester-tests.yml | 16 ++++++++++++++++ .github/workflows/gradle.yml | 3 +++ .github/workflows/ingame-tests.yml | 6 +++++- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/autotester-tests.yml diff --git a/.github/workflows/autotester-tests.yml b/.github/workflows/autotester-tests.yml new file mode 100644 index 000000000..802ab36a3 --- /dev/null +++ b/.github/workflows/autotester-tests.yml @@ -0,0 +1,16 @@ +name: step.autotester-tests + +# Fast, Docker-free tests for the autotester step engine (selectors, conditions, +# templating, the executor, and the shipped scenarios run through a fake bridge). +on: [workflow_call] + +jobs: + autotester-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Run engine tests + working-directory: autotester + run: uv run --group dev pytest diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 0595316a3..5a29a34ab 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -6,6 +6,9 @@ jobs: tests: # runs on linux and windows uses: ./.github/workflows/tests.yml secrets: inherit + autotester-tests: # autotester engine unit tests (no Docker) + uses: ./.github/workflows/autotester-tests.yml + secrets: inherit build: # runs on linux uses: ./.github/workflows/build.yml secrets: inherit \ No newline at end of file diff --git a/.github/workflows/ingame-tests.yml b/.github/workflows/ingame-tests.yml index 7e62164a0..81aa074f3 100644 --- a/.github/workflows/ingame-tests.yml +++ b/.github/workflows/ingame-tests.yml @@ -36,8 +36,12 @@ jobs: run: | echo "scenario=${{ github.event.inputs.scenario || 'sync' }}" >> $GITHUB_OUTPUT + unit: + uses: ./.github/workflows/autotester-tests.yml + secrets: inherit + build: - needs: [prepare] + needs: [prepare, unit] uses: ./.github/workflows/build.yml with: autotest: true