#!/usr/bin/env bash # Builds the world level a few tiles at a time. Which level that is comes from Region.json's `level` # (/Game/Maps/L_World today), never from a name written here. # # Scripts/Authoring/build_region.sh # the whole grid from Region.json, rebuilding the level first # Scripts/Authoring/build_region.sh --append # add whatever is still missing, keep what is there # BATCH=6 Scripts/Authoring/build_region.sh # bigger batches, if the machine has the memory # # One process per batch, because a landscape of a hundred components costs about a gigabyte that the editor # does not give back while the level is open. Thirty-six in one process reached 14.7 GB by the ninth tile # and would have exhausted this machine's commit long before the last; six at a time peaks at 13.6 GB, which # left only 1.2 GB of commit free. Three is the default for that reason. The script itself skips tiles that are # already in the level, so a batch that is rerun costs only the editor's start-up. # # Everything is passed as an absolute path and the log goes to a file of its own. With the editor open and a # relative project path, UnrealEditor-Cmd exits immediately having written nothing at all, which looks exactly # like success: the exit code is zero and there is no output to read. # # The script's own arguments go *inside* the quoted -script= value, not after it. UPythonScriptCommandlet::Main # reads -Script= as one quoted string and hands the whole thing to the Python plugin, which splits it into a # filename and arguments and sets sys.argv from them; anything put after it on the command line is parsed by # the engine and never reaches Python. Passed the wrong way the script sees no arguments and quietly builds # every tile in the grid in one process, which is exactly the run this batching exists to avoid. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" UE_ROOT="${UE_ROOT:-D:/UE_5.8}" CMD="$UE_ROOT/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" PROJECT="$ROOT/Salty.uproject" SCRIPT="$ROOT/Scripts/Authoring/create_region_world.py" LOGS="$ROOT/Saved/Logs" BATCH="${BATCH:-3}" # The grid comes from the manifest, never from a number written here: Region.json is the contract, and a # driver that assumed a 6x6 square would quietly build thirty-six of the ninety-eight tiles and report success. MANIFEST="$ROOT/RawContent/World/Region.json" grid() { grep -oE "\"$1\"[[:space:]]*:[[:space:]]*[0-9]+" "$MANIFEST" | grep -oE '[0-9]+$'; } COLUMNS="${COLUMNS:-$(grid columns)}" ROWS="${ROWS:-$(grid rows)}" if [ -z "$COLUMNS" ] || [ -z "$ROWS" ]; then echo "could not read tiles.columns and tiles.rows from $MANIFEST" >&2 exit 1 fi append=0 if [ "${1:-}" = "--append" ]; then append=1 fi # An editor with the level open holds a write lock on the .umap. The save is the last thing a batch does and # --rebuild is the first, so a locked file does not fail the run harmlessly: it empties the level and *then* # fails, which on 2026-09-20 left twelve of ninety-eight tiles and looked exactly like a corrupted world. # create_region_world.py refuses to start for the same reason; this is the same question asked before thirty # editor start-ups pay for the answer. Opening r+b writes nothing. PYTHON="$UE_ROOT/Engine/Binaries/ThirdParty/Python3/Win64/python.exe" # The level comes from the manifest, like the grid does. A name written here would go stale the moment # Region.json's `level` changed, and the probe would then cheerfully clear a file nothing is about to touch. LEVEL_PATH="$(grep -oE '"level"[[:space:]]*:[[:space:]]*"[^"]+"' "$MANIFEST" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" LEVEL_FILE="$ROOT/Content/Maps/${LEVEL_PATH##*/}.umap" if [ -f "$LEVEL_FILE" ] && [ -x "$PYTHON" ]; then if ! "$PYTHON" -c "import sys; open(sys.argv[1], 'r+b').close()" "$LEVEL_FILE" 2>/dev/null; then echo "$LEVEL_FILE is locked: an editor with that level open holds it." >&2 echo "Close the editor, or load another level in it, then run this again." >&2 exit 1 fi fi tiles=() for ((ty = 0; ty < ROWS; ty++)); do for ((tx = 0; tx < COLUMNS; tx++)); do tiles+=("$tx,$ty") done done mkdir -p "$LOGS" total=${#tiles[@]} echo "=== ${COLUMNS}x${ROWS} = $total tiles, $BATCH a batch" added_all=0 batch_index=0 for ((i = 0; i < total; i += BATCH)); do batch=("${tiles[@]:i:BATCH}") # The first batch of a full run starts the level over, which is also what sweeps any stale actor package an # interrupted run left behind. --append never rebuilds. rebuild="" if [ "$i" -eq 0 ] && [ "$append" -eq 0 ]; then rebuild=" --rebuild" fi log="$LOGS/region_batch_$batch_index.log" echo "=== batch $batch_index: ${batch[*]}${rebuild}" "$CMD" "$PROJECT" -run=pythonscript \ -script="$SCRIPT$rebuild --tiles ${batch[*]}" \ -AllowCommandletRendering -unattended -nopause -abslog="$log" >/dev/null 2>&1 # Not the exit code: UnrealEditor-Cmd returns non-zero whenever anything logged an Error, and this project # logs three on every start (no GameFeatureData asset rule, and the editor already holds MCP's port 8000). # The line the script prints on a successful save is the honest signal. saved=$(grep -c "saved: .* landscape(s) added this run" "$log" 2>/dev/null || true) if [ "${saved:-0}" -eq 0 ]; then echo "batch $batch_index did not save; see $log" >&2 grep -E "LogPython: Error|Python script executed with errors|Traceback" "$log" 2>/dev/null | tail -5 >&2 exit 1 fi added=$(grep -cE "LogPython: \[" "$log" 2>/dev/null || true) added_all=$((added_all + ${added:-0})) echo " $added landscape(s) added" batch_index=$((batch_index + 1)) done echo "done: $added_all landscape(s) added over $batch_index batches; $total tiles in the grid"