6 Commits

Author SHA1 Message Date
Jan Nedbal
b5d046c91a docs: link make-release.sh from update-manager doc 2026-04-14 13:52:27 +02:00
Jan Nedbal
c2804fe1a6 scripts: add make-release.sh 2026-04-14 13:52:23 +02:00
1dba3e3c91 Merge pull request 'docs: design the update manager + manifest generator' (#3) from jann/m2dev-client:claude/update-manager into main
Reviewed-on: metin-server/m2dev-client#3
2026-04-14 11:54:39 +02:00
9c95590099 Merge pull request 'docs: add Linux Wine runtime guide and setup script' (#1) from jann/m2dev-client:claude/linux-wine-runtime into main
Reviewed-on: metin-server/m2dev-client#1
2026-04-14 10:30:03 +02:00
Jan Nedbal
dd0643137f scripts: add wine prefix setup script
Idempotent helper that copies the client to a writable location,
creates a fresh Wine prefix, and installs the required winetricks verbs
(vcrun2022, d3dx9, corefonts, tahoma). Re-running on an existing target
skips already-done steps.
2026-04-14 10:23:04 +02:00
Jan Nedbal
0aa8361f09 docs: add Linux Wine runtime guide
Document the interim path for running the Windows client on Linux via
Wine, verified to reach character selection on Fedora 41 with Wine 10
Staging. Main gotcha: winetricks tahoma is mandatory because the client
hard-codes Tahoma as the UI font, and without it all text renders
invisibly even though layouts are correct.
2026-04-14 10:23:04 +02:00
4 changed files with 372 additions and 6 deletions

91
docs/linux-wine.md Normal file
View File

@@ -0,0 +1,91 @@
# Running the client on Linux with Wine
This is an interim path for playing and testing on Linux while a native Linux port is a longer-term goal. Wine runs the unmodified Windows build of `Metin2.exe` / `Metin2_Debug.exe` directly. Verified to reach the character selection screen on Fedora 41 with Wine 10 Staging; other modern distros should work the same.
Use this when you want to:
- Smoke-test the Windows binary without rebooting into Windows
- Develop server-side with a live client connected from the same machine
- Run a dev loop without owning a Windows install
## Requirements
- A recent Wine (10.x Staging tested, 9.x stable should work). Older than 8 may be rough on D3D9.
- `winetricks` for installing MSVC runtime, D3DX9 helper DLLs, core fonts, and Tahoma
- A copy of the client deploy folder (the one containing `Metin2.exe`, `Metin2_Debug.exe`, `assets/`, `pack/`, `bgm/`, `config/`, `log/`). The whole folder is ~4.3 GB.
- ~7 GB free disk for the writable client copy plus the Wine prefix
On Fedora:
```bash
sudo dnf install -y wine winetricks
```
On Debian/Ubuntu (use the WineHQ repo for a modern version):
```bash
sudo apt install -y wine winetricks
```
## One-shot setup
The easiest way is the helper script in this repo:
```bash
./scripts/setup-wine-prefix.sh /path/to/windows/client ~/metin-wine
```
This will:
1. Copy the client folder to `~/metin-wine/client` (needs to be on a writable filesystem, so an NTFS read-only mount won't do).
2. Create a fresh Wine prefix at `~/metin-wine/prefix`.
3. Install `vcrun2022`, `d3dx9`, `corefonts`, and `tahoma` via winetricks.
4. Print the launch command.
See the script itself for exact steps if you prefer to run them manually.
## Why Tahoma is required
The client hard-codes Tahoma as its UI font. On Windows this is invisible because Tahoma ships with the OS; on a fresh Wine prefix it's missing, and the result is that the login screen renders layouts and backgrounds correctly but **all text is invisible**. You can reach the server picker and character selection, you just can't read anything. Installing Tahoma via `winetricks tahoma` fixes it in one shot.
If the login screen looks right but has no readable text, this is what you're seeing.
## Launching
After setup, the launch command is just:
```bash
cd ~/metin-wine/client
WINEPREFIX=~/metin-wine/prefix wine Metin2.exe
```
Use `Metin2_Debug.exe` instead of `Metin2.exe` if you want more verbose client-side logging via `OutputDebugString`. Wine will echo those to stderr when `WINEDEBUG` includes `+seh` or you pass `+outputdebugstring`. For normal play use `-all,+err`.
## Logs and debug output
Useful `WINEDEBUG` settings:
- `WINEDEBUG=-all,+err` — quiet, only real errors. Use this for normal play.
- `WINEDEBUG=-all,+loaddll,+module,+err` — shows which DLLs Wine loads, handy when the client crashes early with a missing DLL.
- `WINEDEBUG=-all,+err,+seh` — captures the client's own `OutputDebugString` calls via SEH, which is how metin2's internal logging surfaces. Very noisy but useful when diagnosing client-side issues ("CResource::Load file not exist X", "CPythonNonPlayer::LoadNonPlayerData", etc.).
Redirect to a file and grep the signal out of the noise:
```bash
WINEDEBUG=-all,+err,+seh wine Metin2_Debug.exe >wine-run.log 2>&1
grep -E 'OutputDebugString[AW] "' wine-run.log | sed 's/.*OutputDebugString[AW] //' | sort -u
```
The client also writes its own logs to `log/` inside the client folder. Those are plain text and more readable than the Wine SEH traces.
## Known quirks
- **Wayland:** works via XWayland, no special config. If the window opens minimized or off-screen, `Alt+Tab` to find it.
- **Read-only NTFS mount:** don't try to launch from a read-only mount of your Windows partition. The client creates and writes `log/`, `config/`, and cache files; on a read-only FS the launch will be confusing. Always copy to a writable location first. `setup-wine-prefix.sh` does this for you.
- **DXVK render state warnings:** lines like `D3D9DeviceEx::SetRenderState: Unhandled render state 163` in the log are harmless. DXVK doesn't implement every legacy D3D9 render state, but the ones metin2 cares about all work.
- **SEH dispatch spam:** `dispatch_exception code=4001000a` / `4001000c` are how Windows signals `OutputDebugStringW` / `OutputDebugStringA`. They're soft exceptions, not errors. They only show up if you enable `+seh` in `WINEDEBUG`.
- **First launch is slower:** DXVK compiles its shader pipelines on first run and writes a state cache. Subsequent launches are noticeably faster.
## When to stop using Wine
This guide is for the interim. The longer-term plan is a native Linux build of the client with a free-software replacement for Granny2 animation runtime. Until that lands, Wine is the way.

View File

@@ -163,14 +163,23 @@ Files under `.updates/` are created by the launcher. The user shouldn't touch th
1. On a trusted machine (not random laptop), with the private signing key present:
```bash
./scripts/make-release.sh --version 2026.04.14-1 --source /path/to/fresh/client
./scripts/make-release.sh --source /path/to/fresh/client --version 2026.04.14-1 \
--previous 2026.04.13-3 --notes notes.md --dry-run
```
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory `release/2026.04.14-1/` containing the manifest, its signature, and only the new blobs (ones not already present on the server).
[`scripts/make-release.sh`](../scripts/make-release.sh) is the single entry
point for the v1 manual flow. It drives `make-manifest.py` + `sign-manifest.py`,
builds the content-addressed blob tree under `files/<hash[0:2]>/<hash>` with
hardlink-based deduplication, archives the signed manifest into
`manifests/<version>.json`, and — unless `--dry-run` is passed — rsyncs the
blob tree first and the `manifest.json` + `manifest.json.sig` pair last so the
release becomes visible atomically. Flags: `--key` (default
`~/.config/metin/launcher-signing-key`, must be mode 600), `--out` (default
`/tmp/release-<version>`), `--force` to overwrite a non-empty out dir, `--yes`
to skip the interactive rsync confirmation, `--rsync-target <user@host:/path>`
to override the upload destination.
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory containing the manifest, its signature, and the deduplicated blob tree.
3. Human review: diff the new manifest against the previous one, sanity-check size and file count.
4. `rsync` the release directory to the VPS:
```bash
rsync -av release/2026.04.14-1/ mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/
```
4. Re-run without `--dry-run` (same args) to rsync to the VPS. The script prints the target and waits for confirmation unless `--yes` is passed.
5. Verify from a second machine: `curl` the manifest, check signature, check a random blob.
6. Tag the release in git.

169
scripts/make-release.sh Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# make-release.sh — assemble, sign, and (optionally) publish a client release.
#
# Drives scripts/make-manifest.py + scripts/sign-manifest.py, then builds the
# content-addressed blob tree the launcher pulls from. See docs/update-manager.md
# for the end-to-end design; this script is the v1 manual publishing flow.
#
# Usage:
# scripts/make-release.sh --source <client-dir> --version <version> \
# [--previous <version>] [--notes <file>] \
# [--key <path>] [--out <path>] [--force] \
# [--dry-run] [--yes] [--rsync-target <user@host:/path>]
set -euo pipefail
# -------- arg parsing --------
SOURCE=""
VERSION=""
PREVIOUS=""
NOTES_FILE=""
KEY="${HOME}/.config/metin/launcher-signing-key"
OUT=""
FORCE=0
DRY_RUN=0
YES=0
RSYNC_TARGET="mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/"
die() { echo "error: $*" >&2; exit 1; }
say() { echo "[make-release] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--source) SOURCE="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--previous) PREVIOUS="$2"; shift 2 ;;
--notes) NOTES_FILE="$2"; shift 2 ;;
--key) KEY="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--force) FORCE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--yes) YES=1; shift ;;
--rsync-target) RSYNC_TARGET="$2"; shift 2 ;;
-h|--help) sed -n '1,15p' "$0"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
[[ -n "$SOURCE" ]] || die "--source is required"
[[ -n "$VERSION" ]] || die "--version is required"
[[ -d "$SOURCE" ]] || die "source dir does not exist: $SOURCE"
[[ -f "$SOURCE/Metin2.exe" ]] || die "source does not look like a client dir (missing Metin2.exe): $SOURCE"
[[ -f "$KEY" ]] || die "signing key not found: $KEY"
KEY_MODE=$(stat -c '%a' "$KEY")
[[ "$KEY_MODE" == "600" ]] || die "signing key $KEY must be mode 600, got $KEY_MODE"
[[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]] && die "notes file not found: $NOTES_FILE"
: "${OUT:=/tmp/release-${VERSION}}"
SOURCE=$(cd "$SOURCE" && pwd)
OUT_ABS=$(mkdir -p "$OUT" && cd "$OUT" && pwd)
if [[ -n "$(ls -A "$OUT_ABS" 2>/dev/null)" && "$FORCE" -ne 1 ]]; then
die "output directory $OUT_ABS is non-empty (use --force to overwrite)"
fi
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
say "source: $SOURCE"
say "version: $VERSION"
say "out: $OUT_ABS"
say "key: $KEY"
# -------- [1/6] manifest --------
say "[1/6] building manifest"
mkdir -p "$OUT_ABS/manifests" "$OUT_ABS/files"
MANIFEST="$OUT_ABS/manifest.json"
mk_args=(--source "$SOURCE" --version "$VERSION" --out "$MANIFEST")
[[ -n "$PREVIOUS" ]] && mk_args+=(--previous "$PREVIOUS")
if [[ -n "$NOTES_FILE" ]]; then
notes_text=$(cat "$NOTES_FILE")
mk_args+=(--notes "$notes_text")
fi
python3 "$SCRIPT_DIR/make-manifest.py" "${mk_args[@]}"
# -------- [2/6] sign --------
say "[2/6] signing manifest"
python3 "$SCRIPT_DIR/sign-manifest.py" --manifest "$MANIFEST" --key "$KEY"
SIG="$MANIFEST.sig"
[[ -f "$SIG" ]] || die "signature not produced"
sig_len=$(stat -c '%s' "$SIG")
[[ "$sig_len" == "64" ]] || die "signature is $sig_len bytes, expected 64"
# -------- [3/6] archive historical manifest --------
say "[3/6] archiving historical manifest -> manifests/${VERSION}.json"
cp -f "$MANIFEST" "$OUT_ABS/manifests/${VERSION}.json"
cp -f "$SIG" "$OUT_ABS/manifests/${VERSION}.json.sig"
# -------- [4/6] blob tree --------
say "[4/6] building content-addressed blob tree"
# Extract (path, sha256) pairs for launcher + every file entry.
mapfile -t PAIRS < <(jq -r '
([.launcher] + .files)
| .[]
| "\(.sha256)\t\(.path)"
' "$MANIFEST")
total_entries=${#PAIRS[@]}
unique_count=0
dedup_count=0
bytes_written=0
declare -A SEEN
for pair in "${PAIRS[@]}"; do
hash="${pair%%$'\t'*}"
rel="${pair#*$'\t'}"
src="$SOURCE/$rel"
[[ -f "$src" ]] || die "file in manifest missing from source: $rel"
if [[ -n "${SEEN[$hash]:-}" ]]; then
dedup_count=$((dedup_count + 1))
continue
fi
SEEN[$hash]=1
unique_count=$((unique_count + 1))
prefix="${hash:0:2}"
dst_dir="$OUT_ABS/files/$prefix"
dst="$dst_dir/$hash"
mkdir -p "$dst_dir"
if [[ -f "$dst" ]]; then
continue
fi
# Try hardlink first, fall back to copy across filesystems.
if ! cp -l "$src" "$dst" 2>/dev/null; then
cp "$src" "$dst"
fi
sz=$(stat -c '%s' "$dst")
bytes_written=$((bytes_written + sz))
done
say " entries: $total_entries unique blobs: $unique_count deduped: $dedup_count"
say " bytes written: $bytes_written"
# -------- [5/6] layout summary --------
say "[5/6] final layout:"
(cd "$OUT_ABS" && find . -maxdepth 2 -mindepth 1 -printf ' %p\n' | sort | head -40)
# -------- [6/6] rsync --------
if [[ "$DRY_RUN" -eq 1 ]]; then
say "[6/6] --dry-run set, skipping rsync. target would be: $RSYNC_TARGET"
exit 0
fi
say "[6/6] rsync target: $RSYNC_TARGET"
if [[ "$YES" -ne 1 ]]; then
read -r -p "continue? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "aborted by user"
fi
# Stage 1: everything except manifest.json(.sig) — blobs and historical archive.
rsync -av --delay-updates --checksum --omit-dir-times --no-perms \
--exclude 'manifest.json' --exclude 'manifest.json.sig' \
"$OUT_ABS"/ "$RSYNC_TARGET"
# Stage 2: manifest + signature, so the new release becomes visible last.
rsync -av --checksum --omit-dir-times --no-perms \
"$MANIFEST" "$SIG" "$RSYNC_TARGET"
say "done."

97
scripts/setup-wine-prefix.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Set up a Wine prefix for running the Metin2 client on Linux.
# Idempotent: re-running on an existing prefix skips steps that are already done.
#
# Usage:
# ./scripts/setup-wine-prefix.sh <source-client-dir> <target-dir>
#
# Example:
# ./scripts/setup-wine-prefix.sh /mnt/windows_c/Users/me/metin/client ~/metin-wine
#
# Result layout:
# <target-dir>/client/ — writable copy of the client deploy folder
# <target-dir>/prefix/ — Wine prefix with required runtime deps installed
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "usage: $0 <source-client-dir> <target-dir>" >&2
echo " source-client-dir: path containing Metin2.exe, assets/, pack/, etc." >&2
echo " target-dir: directory to create (holds client/ and prefix/)" >&2
exit 2
fi
SRC=$1
DEST=$2
if [[ ! -f "$SRC/Metin2.exe" && ! -f "$SRC/Metin2_Debug.exe" ]]; then
echo "error: $SRC does not look like a client folder (no Metin2.exe or Metin2_Debug.exe)" >&2
exit 1
fi
for tool in wine winetricks; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found in PATH. Install it via your package manager." >&2
exit 1
fi
done
CLIENT_DIR=$DEST/client
PREFIX_DIR=$DEST/prefix
mkdir -p "$DEST"
if [[ -d "$CLIENT_DIR" && -f "$CLIENT_DIR/Metin2.exe" ]] || [[ -d "$CLIENT_DIR" && -f "$CLIENT_DIR/Metin2_Debug.exe" ]]; then
echo "[1/3] client already present at $CLIENT_DIR, skipping copy"
else
echo "[1/3] copying client from $SRC to $CLIENT_DIR (this can take a minute)"
cp -a "$SRC" "$CLIENT_DIR"
fi
export WINEPREFIX=$PREFIX_DIR
export WINEARCH=win64
if [[ -f "$PREFIX_DIR/system.reg" ]]; then
echo "[2/3] wine prefix already exists at $PREFIX_DIR, skipping wineboot"
else
echo "[2/3] creating wine prefix at $PREFIX_DIR"
mkdir -p "$PREFIX_DIR"
wineboot --init >/dev/null 2>&1 || true
fi
# vcrun2022 — MSVC 2015-2022 runtime, required because the client is an MSVC build
# d3dx9 — D3DX9 helper DLLs (Wine implements d3d9 but not the d3dx9 helpers)
# corefonts — Arial/Courier/Times/etc., needed by some UI elements
# tahoma — the client hard-codes Tahoma as the UI font; without it, all text renders invisibly
VERBS=(vcrun2022 d3dx9 corefonts tahoma)
TO_INSTALL=()
for v in "${VERBS[@]}"; do
case $v in
vcrun2022)
if [[ -f "$PREFIX_DIR/drive_c/windows/system32/msvcp140.dll" ]]; then continue; fi ;;
d3dx9)
if [[ -f "$PREFIX_DIR/drive_c/windows/system32/d3dx9_43.dll" ]]; then continue; fi ;;
corefonts)
if [[ -f "$PREFIX_DIR/drive_c/windows/Fonts/arial.ttf" ]]; then continue; fi ;;
tahoma)
if [[ -f "$PREFIX_DIR/drive_c/windows/Fonts/tahoma.ttf" ]]; then continue; fi ;;
esac
TO_INSTALL+=("$v")
done
if [[ ${#TO_INSTALL[@]} -eq 0 ]]; then
echo "[3/3] all winetricks verbs already installed"
else
echo "[3/3] installing winetricks verbs: ${TO_INSTALL[*]}"
winetricks -q "${TO_INSTALL[@]}"
fi
echo
echo "done. to launch:"
echo
echo " cd $CLIENT_DIR"
echo " WINEPREFIX=$PREFIX_DIR wine Metin2.exe"
echo
echo "or with verbose client logging:"
echo
echo " WINEPREFIX=$PREFIX_DIR WINEDEBUG=-all,+err,+seh wine Metin2_Debug.exe"