initial commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
# SymlinkModels
|
||||||
|
|
||||||
|
Replace unwanted Sven Co-op player models with a neutral placeholder by symlinking them to `helmet.mdl`. This keeps your client from rendering anime, NSFW, or other models you don't want to see, while still letting you connect to any server that uses them.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
For each model ID, the scripts create a relative symlink:
|
||||||
|
|
||||||
|
```
|
||||||
|
svencoop_addon/models/player/<id>/<id>.mdl
|
||||||
|
-> ../../../../svencoop/models/player/helmet/helmet.mdl
|
||||||
|
```
|
||||||
|
|
||||||
|
Every suppressed model renders as the default helmet instead.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Sven Co-op installed (scripts must run from the game's root folder, alongside `tags.json`)
|
||||||
|
- Python 3.8+ **or** PowerShell 5.1+
|
||||||
|
- **Windows only:** Developer Mode enabled *(Settings → For developers)* **or** run as Administrator — both Windows symlink scripts check for this and warn if neither condition is met
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|---|---|
|
||||||
|
| `tags.json` | Model ID lists grouped by category (`anime`, `nsfw`, `vehicle`) |
|
||||||
|
| `symlink_models.py` | Python — symlink models from `tags.json`, a server path, or a text file |
|
||||||
|
| `symlink_models.ps1` | PowerShell equivalent of `symlink_models.py` |
|
||||||
|
| `symlink_downloads.py` | Python — symlink downloaded models that match `tags.json`, then optionally clean up `svencoop_downloads` |
|
||||||
|
| `symlink_downloads.ps1` | PowerShell equivalent of `symlink_downloads.py` |
|
||||||
|
| `server_models.txt` | Example model ID dump from a server, usable with `--from-file` |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Place the scripts and `tags.json` in your **Sven Co-op root folder** (the directory that contains `svencoop/` and `svencoop_addon/`).
|
||||||
|
|
||||||
|
### symlink_models — bulk symlink by source
|
||||||
|
|
||||||
|
Symlinks every model ID from the selected source.
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```bash
|
||||||
|
# Default: all IDs in tags.json
|
||||||
|
python symlink_models.py
|
||||||
|
|
||||||
|
# Scan a server's player/ directory
|
||||||
|
python symlink_models.py --server /path/to/svencoop_addon/models/player
|
||||||
|
|
||||||
|
# Read IDs from a text file (one ID per line)
|
||||||
|
python symlink_models.py --from-file server_models.txt
|
||||||
|
|
||||||
|
# Preview changes without writing anything
|
||||||
|
python symlink_models.py --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**PowerShell:**
|
||||||
|
```powershell
|
||||||
|
# Default: all IDs in tags.json
|
||||||
|
.\symlink_models.ps1
|
||||||
|
|
||||||
|
# Scan a server's player/ directory
|
||||||
|
.\symlink_models.ps1 -Server "C:\path\to\svencoop_addon\models\player"
|
||||||
|
|
||||||
|
# Read IDs from a text file
|
||||||
|
.\symlink_models.ps1 -FromFile server_models.txt
|
||||||
|
|
||||||
|
# Preview changes without writing anything
|
||||||
|
.\symlink_models.ps1 -DryRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### symlink_downloads — process already-downloaded models
|
||||||
|
|
||||||
|
Scans `svencoop_downloads/models/player/` for `.mdl` files whose names match IDs in `tags.json`. For each match it creates the symlink in `svencoop_addon`, then lists the source folders and prompts whether to delete them.
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```bash
|
||||||
|
python symlink_downloads.py
|
||||||
|
|
||||||
|
# Preview without writing or deleting
|
||||||
|
python symlink_downloads.py --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**PowerShell:**
|
||||||
|
```powershell
|
||||||
|
.\symlink_downloads.py
|
||||||
|
|
||||||
|
# Preview without writing or deleting
|
||||||
|
.\symlink_downloads.ps1 -DryRun
|
||||||
|
```
|
||||||
|
|
||||||
|
The `svencoop_downloads/models/player/` root itself is never deleted — only matched sub-folders.
|
||||||
|
|
||||||
|
### Dumping model IDs from a server
|
||||||
|
|
||||||
|
If you have access to a server's files (directly or via a network mount), you can dump its player model IDs to a text file and feed it to `symlink_models`:
|
||||||
|
|
||||||
|
**Linux/macOS:**
|
||||||
|
```bash
|
||||||
|
find /path/to/svencoop_addon/models/player \
|
||||||
|
-mindepth 2 -maxdepth 2 -name "*.mdl" -printf "%f\n" \
|
||||||
|
| sed 's/\.mdl$//' > server_models.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**PowerShell:**
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem -Path "C:\path\to\svencoop_addon\models\player" -Recurse -Filter "*.mdl" |
|
||||||
|
Where-Object { $_.DirectoryName -ne "...\player" } |
|
||||||
|
ForEach-Object { $_.BaseName } | Sort-Object -Unique | Set-Content server_models.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## tags.json
|
||||||
|
|
||||||
|
The tag file is a flat JSON object with three category arrays:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anime": ["2d_ab_kanna_coco", "sagas_trunks_ssj", ...],
|
||||||
|
"nsfw": [...],
|
||||||
|
"vehicle": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **anime** (~2,129 entries) — anime character models
|
||||||
|
- **nsfw** (~579 entries) — adult content models
|
||||||
|
- **vehicle** (~73 entries) — vehicle models
|
||||||
|
|
||||||
|
Model IDs are lowercase with underscores or hyphens. Version suffixes like `_v2` and `_hd` are treated as separate entries. To add or remove a model, edit the relevant array directly. Existing entries are not fully sorted, but try to insert near alphabetical neighbours.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Each script prints one line per model processed and a summary on exit:
|
||||||
|
|
||||||
|
```
|
||||||
|
[anime] — 2129 models
|
||||||
|
create : 2d_ab_kanna_coco
|
||||||
|
skipped : 2d_ab_kratos
|
||||||
|
replace : sagas_trunks_ssj
|
||||||
|
|
||||||
|
147 symlinks written (147 new, 0 replaced), 1982 already correct
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes are 0 on success, non-zero on a fatal error (missing `helmet.mdl`, bad path, etc.).
|
||||||
Executable
+11835
File diff suppressed because it is too large
Load Diff
Executable
+190
@@ -0,0 +1,190 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
symlink_downloads.ps1 -- place in the "Sven Co-op" root folder alongside tags.json.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
1. Scans svencoop_downloads\models\player\ for .mdl files whose name matches a
|
||||||
|
model ID in tags.json (case-insensitive).
|
||||||
|
2. Creates a relative symlink in svencoop_addon\models\player\ for each match:
|
||||||
|
<id>\<id>.mdl -> ..\..\..\..\svencoop\models\player\helmet\helmet.mdl
|
||||||
|
3. Lists matched folders and prompts to delete them from svencoop_downloads.
|
||||||
|
The player\ root itself is never deleted.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell 5.1+ and either:
|
||||||
|
- Developer Mode enabled (Settings > For developers)
|
||||||
|
- Running as Administrator
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Paths ---
|
||||||
|
$scriptDir = $PSScriptRoot
|
||||||
|
$tagsPath = Join-Path $scriptDir "tags.json"
|
||||||
|
$helmetAbs = Join-Path $scriptDir "svencoop\models\player\helmet\helmet.mdl"
|
||||||
|
$dlRoot = Join-Path $scriptDir "svencoop_downloads\models\player"
|
||||||
|
$addonRoot = Join-Path $scriptDir "svencoop_addon\models\player"
|
||||||
|
$symlinkTarget = "..\..\..\..\svencoop\models\player\helmet\helmet.mdl"
|
||||||
|
|
||||||
|
# --- Pre-flight checks ---
|
||||||
|
if (-not (Test-Path $tagsPath)) {
|
||||||
|
Write-Error "tags.json not found next to script ($tagsPath)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $helmetAbs)) {
|
||||||
|
Write-Error "helmet.mdl not found: $helmetAbs`nRun from the Sven Co-op root folder."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $dlRoot)) {
|
||||||
|
Write-Error "Downloads folder not found: $dlRoot"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for symlink privilege on Windows
|
||||||
|
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||||
|
[Security.Principal.WindowsBuiltInRole]::Administrator
|
||||||
|
)
|
||||||
|
if (-not $isAdmin) {
|
||||||
|
$devMode = $false
|
||||||
|
try {
|
||||||
|
$dm = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" -ErrorAction SilentlyContinue
|
||||||
|
$devMode = $dm.AllowDevelopmentWithoutDevLicense -eq 1
|
||||||
|
} catch {}
|
||||||
|
if (-not $devMode) {
|
||||||
|
Write-Warning @"
|
||||||
|
Symlink creation may fail. To fix, either:
|
||||||
|
- Enable Developer Mode (Settings > For developers > Developer Mode)
|
||||||
|
- Re-run this script as Administrator
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Build case-insensitive tag lookup: mdl stem -> canonical ID ---
|
||||||
|
$tags = Get-Content $tagsPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
$tagLookup = @{}
|
||||||
|
foreach ($category in $tags.PSObject.Properties) {
|
||||||
|
foreach ($id in $category.Value) {
|
||||||
|
$tagLookup[$id] = $id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Scan downloads for matching .mdl files ---
|
||||||
|
$folderMdls = @{}
|
||||||
|
$matched = [System.Collections.Generic.List[hashtable]]::new()
|
||||||
|
|
||||||
|
Get-ChildItem -Path $dlRoot -Recurse -Filter "*.mdl" | ForEach-Object {
|
||||||
|
$dir = $_.DirectoryName
|
||||||
|
if ($dir -eq $dlRoot) { return }
|
||||||
|
|
||||||
|
if (-not $folderMdls.ContainsKey($dir)) { $folderMdls[$dir] = @() }
|
||||||
|
$folderMdls[$dir] += $_
|
||||||
|
|
||||||
|
$tagsId = $tagLookup[$_.BaseName]
|
||||||
|
if ($tagsId) {
|
||||||
|
$matched.Add(@{ File = $_; TagsId = $tagsId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matched.Count -eq 0) {
|
||||||
|
Write-Host "No downloaded .mdl files matched any model ID in tags.json."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Symlink pass ---
|
||||||
|
$prefix = if ($DryRun) { "[DRY RUN] " } else { "" }
|
||||||
|
Write-Host "${prefix}Symlinking $($matched.Count) matched model(s):`n"
|
||||||
|
|
||||||
|
$counts = @{ created = 0; replaced = 0; ok = 0; error = 0 }
|
||||||
|
|
||||||
|
foreach ($m in $matched) {
|
||||||
|
$tagsId = $m.TagsId
|
||||||
|
$addonMdl = Join-Path $addonRoot "$tagsId\$tagsId.mdl"
|
||||||
|
$addonDir = Split-Path $addonMdl -Parent
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
$link = Get-Item $addonMdl -ErrorAction SilentlyContinue
|
||||||
|
if ($link -and $link.LinkType -and $link.Target -eq $symlinkTarget) {
|
||||||
|
$status = "ok"
|
||||||
|
} else {
|
||||||
|
$status = "would link"
|
||||||
|
}
|
||||||
|
Write-Host (" {0,-12} {1} (from {2})" -f $status, $tagsId, $m.File.FullName.Substring($dlRoot.Length + 1))
|
||||||
|
$counts.created++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Test-Path $addonMdl) { Remove-Item $addonMdl -Force }
|
||||||
|
if (-not (Test-Path $addonDir)) { New-Item -ItemType Directory -Path $addonDir -Force | Out-Null }
|
||||||
|
|
||||||
|
$existing = Get-Item $addonMdl -ErrorAction SilentlyContinue
|
||||||
|
if ($existing -and $existing.LinkType -and $existing.Target -eq $symlinkTarget) {
|
||||||
|
Write-Host " already ok $tagsId"
|
||||||
|
$counts.ok++
|
||||||
|
} else {
|
||||||
|
New-Item -ItemType SymbolicLink -Path $addonMdl -Target $symlinkTarget | Out-Null
|
||||||
|
Write-Host " linked $tagsId"
|
||||||
|
$counts.created++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR ${tagsId}: $_"
|
||||||
|
$counts.error++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Write-Host ("`n $($counts.created) created, $($counts.replaced) replaced, $($counts.ok) already correct, $($counts.error) errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Cleanup prompt ---
|
||||||
|
$foldersToClean = @{}
|
||||||
|
foreach ($m in $matched) {
|
||||||
|
$dir = $m.File.DirectoryName
|
||||||
|
if (-not $foldersToClean.ContainsKey($dir)) { $foldersToClean[$dir] = @() }
|
||||||
|
$foldersToClean[$dir] += $m.File
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nFolders to delete from svencoop_downloads ($($foldersToClean.Count)):`n"
|
||||||
|
foreach ($dir in ($foldersToClean.Keys | Sort-Object)) {
|
||||||
|
$matchedHere = $foldersToClean[$dir]
|
||||||
|
$matchedPaths = $matchedHere | ForEach-Object { $_.FullName }
|
||||||
|
$other = $folderMdls[$dir] | Where-Object { $_.FullName -notin $matchedPaths }
|
||||||
|
$relDir = $dir.Substring($scriptDir.Length + 1)
|
||||||
|
Write-Host " $relDir\"
|
||||||
|
foreach ($f in $matchedHere) { Write-Host " [tagged] $($f.Name)" }
|
||||||
|
foreach ($f in $other) { Write-Host " [extra ] $($f.Name)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "[DRY RUN] Skipping delete prompt."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$answer = Read-Host "Delete these folders from svencoop_downloads? [y/N]"
|
||||||
|
if ($answer -ne "y") {
|
||||||
|
Write-Host "Skipped cleanup."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = 0
|
||||||
|
foreach ($dir in ($foldersToClean.Keys | Sort-Object)) {
|
||||||
|
if ($dir -eq $dlRoot) {
|
||||||
|
Write-Host " SKIP (is player\ root): $dir"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Remove-Item -Path $dir -Recurse -Force
|
||||||
|
Write-Host " deleted $($dir.Substring($scriptDir.Length + 1))\"
|
||||||
|
$deleted++
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR $([System.IO.Path]::GetFileName($dir)): $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nDeleted $deleted folder(s)."
|
||||||
Executable
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
symlink_downloads.py — place in the "Sven Co-op" root folder alongside tags.json.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Scan svencoop_downloads/models/player/ for .mdl files whose stem matches a
|
||||||
|
model ID in tags.json (case-insensitive).
|
||||||
|
2. For each match, create a relative symlink in svencoop_addon/models/player/:
|
||||||
|
<id>/<id>.mdl -> ../../../../svencoop/models/player/helmet/helmet.mdl
|
||||||
|
3. Show matched folders, then prompt to delete them from svencoop_downloads.
|
||||||
|
The player/ root itself is never deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HELMET_REL = Path("svencoop") / "models" / "player" / "helmet" / "helmet.mdl"
|
||||||
|
ADDON_PLAYERS = Path("svencoop_addon") / "models" / "player"
|
||||||
|
DL_PLAYERS = Path("svencoop_downloads") / "models" / "player"
|
||||||
|
SYMLINK_TARGET = Path("..") / ".." / ".." / ".." / HELMET_REL
|
||||||
|
|
||||||
|
|
||||||
|
def write_symlink(mdl_path: Path) -> str:
|
||||||
|
"""Create or update the symlink. Returns 'created', 'replaced', or 'ok'."""
|
||||||
|
target_str = str(SYMLINK_TARGET)
|
||||||
|
if mdl_path.is_symlink():
|
||||||
|
if os.readlink(mdl_path) == target_str:
|
||||||
|
return "ok"
|
||||||
|
mdl_path.unlink()
|
||||||
|
elif mdl_path.exists():
|
||||||
|
mdl_path.unlink()
|
||||||
|
mdl_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.symlink(target_str, mdl_path)
|
||||||
|
return "created"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dry_run = "--dry-run" in sys.argv
|
||||||
|
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
tags_path = script_dir / "tags.json"
|
||||||
|
|
||||||
|
if not tags_path.exists():
|
||||||
|
sys.exit(f"tags.json not found next to script ({tags_path})")
|
||||||
|
|
||||||
|
helmet_abs = script_dir / HELMET_REL
|
||||||
|
if not helmet_abs.exists():
|
||||||
|
sys.exit(f"helmet.mdl not found: {helmet_abs}\nRun from the Sven Co-op root folder.")
|
||||||
|
|
||||||
|
dl_root = script_dir / DL_PLAYERS
|
||||||
|
if not dl_root.is_dir():
|
||||||
|
sys.exit(f"Downloads folder not found: {dl_root}")
|
||||||
|
|
||||||
|
addon_root = script_dir / ADDON_PLAYERS
|
||||||
|
|
||||||
|
with open(tags_path, encoding="utf-8") as f:
|
||||||
|
tags: dict[str, list[str]] = json.load(f)
|
||||||
|
|
||||||
|
# Build case-insensitive lookup: lowercase stem -> canonical tags.json ID
|
||||||
|
tag_lookup: dict[str, str] = {
|
||||||
|
model_id.lower(): model_id
|
||||||
|
for model_ids in tags.values()
|
||||||
|
for model_id in model_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scan downloads: collect all .mdl paths and identify matches
|
||||||
|
# folder_mdls: folder -> all .mdl files in that folder
|
||||||
|
# matched: list of (dl_mdl_path, canonical_tags_id)
|
||||||
|
folder_mdls: dict[Path, list[Path]] = defaultdict(list)
|
||||||
|
matched: list[tuple[Path, str]] = []
|
||||||
|
|
||||||
|
for mdl_path in sorted(dl_root.rglob("*.mdl")):
|
||||||
|
parent = mdl_path.parent
|
||||||
|
if parent == dl_root:
|
||||||
|
continue # skip .mdl files sitting directly in player/ (no subfolder to delete)
|
||||||
|
folder_mdls[parent].append(mdl_path)
|
||||||
|
tags_id = tag_lookup.get(mdl_path.stem.lower())
|
||||||
|
if tags_id:
|
||||||
|
matched.append((mdl_path, tags_id))
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
print("No downloaded .mdl files matched any model ID in tags.json.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Symlink pass ---
|
||||||
|
print(f"{'[DRY RUN] ' if dry_run else ''}Symlinking {len(matched)} matched model(s):\n")
|
||||||
|
counts = {"created": 0, "replaced": 0, "ok": 0, "error": 0}
|
||||||
|
|
||||||
|
for dl_mdl, tags_id in matched:
|
||||||
|
addon_mdl = addon_root / tags_id / f"{tags_id}.mdl"
|
||||||
|
if dry_run:
|
||||||
|
existing = "ok" if (addon_mdl.is_symlink() and os.readlink(addon_mdl) == str(SYMLINK_TARGET)) else "would link"
|
||||||
|
print(f" {existing:<12} {tags_id} (from {dl_mdl.relative_to(dl_root)})")
|
||||||
|
counts["created"] += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = write_symlink(addon_mdl)
|
||||||
|
counts[result] += 1
|
||||||
|
label = {"created": "linked ", "replaced": "replaced ", "ok": "already ok"}.get(result, result)
|
||||||
|
print(f" {label} {tags_id}")
|
||||||
|
except OSError as exc:
|
||||||
|
counts["error"] += 1
|
||||||
|
print(f" ERROR {tags_id}: {exc}")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
print(
|
||||||
|
f"\n {counts['created']} created, {counts['replaced']} replaced, "
|
||||||
|
f"{counts['ok']} already correct, {counts['error']} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Cleanup prompt ---
|
||||||
|
# Any folder containing at least one tagged .mdl is deleted entirely.
|
||||||
|
# The player/ root itself is never deleted.
|
||||||
|
folders_to_clean: dict[Path, list[Path]] = defaultdict(list)
|
||||||
|
for dl_mdl, _ in matched:
|
||||||
|
folders_to_clean[dl_mdl.parent].append(dl_mdl)
|
||||||
|
|
||||||
|
print(f"\nFolders to delete from svencoop_downloads ({len(folders_to_clean)}):\n")
|
||||||
|
for folder in sorted(folders_to_clean):
|
||||||
|
matched_here = folders_to_clean[folder]
|
||||||
|
other = [p for p in folder_mdls[folder] if p not in matched_here]
|
||||||
|
print(f" {folder.relative_to(script_dir)}/")
|
||||||
|
for m in matched_here:
|
||||||
|
print(f" [tagged] {m.name}")
|
||||||
|
for o in other:
|
||||||
|
print(f" [extra ] {o.name}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if dry_run:
|
||||||
|
print("[DRY RUN] Skipping delete prompt.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
answer = input("Delete these folders from svencoop_downloads? [y/N] ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nAborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if answer != "y":
|
||||||
|
print("Skipped cleanup.")
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for folder in sorted(folders_to_clean):
|
||||||
|
if folder == dl_root:
|
||||||
|
print(f" SKIP (is player/ root): {folder}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shutil.rmtree(folder)
|
||||||
|
print(f" deleted {folder.relative_to(script_dir)}/")
|
||||||
|
deleted += 1
|
||||||
|
except OSError as exc:
|
||||||
|
print(f" ERROR {folder.name}: {exc}")
|
||||||
|
|
||||||
|
print(f"\nDeleted {deleted} folder(s).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+154
@@ -0,0 +1,154 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
symlink_models.ps1 -- place in the "Sven Co-op" root folder alongside tags.json.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Creates a relative symlink for every model:
|
||||||
|
svencoop_addon\models\player\<id>\<id>.mdl -> ..\..\..\..\ svencoop\models\player\helmet\helmet.mdl
|
||||||
|
|
||||||
|
Sources (pick one, or default to tags.json):
|
||||||
|
default -- model IDs from tags.json, grouped by category
|
||||||
|
-Server PATH -- scan any svencoop_addon\models\player\ directory for .mdl files
|
||||||
|
-FromFile FILE -- read one model ID per line from a text file
|
||||||
|
|
||||||
|
Dump server model IDs to a file (run on the server):
|
||||||
|
Get-ChildItem -Path "C:\path\to\svencoop_addon\models\player" -Recurse -Filter "*.mdl" |
|
||||||
|
Where-Object { $_.DirectoryName -ne "...\player" } |
|
||||||
|
ForEach-Object { $_.BaseName } | Sort-Object -Unique | Set-Content server_models.txt
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell 5.1+ and either:
|
||||||
|
- Developer Mode enabled (Settings > For developers)
|
||||||
|
- Running as Administrator
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$DryRun,
|
||||||
|
[string]$Server,
|
||||||
|
[string]$FromFile
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Paths ---
|
||||||
|
$scriptDir = $PSScriptRoot
|
||||||
|
$helmetAbs = Join-Path $scriptDir "svencoop\models\player\helmet\helmet.mdl"
|
||||||
|
$addonRoot = Join-Path $scriptDir "svencoop_addon\models\player"
|
||||||
|
$symlinkTarget = "..\..\..\..\svencoop\models\player\helmet\helmet.mdl"
|
||||||
|
|
||||||
|
# --- Pre-flight ---
|
||||||
|
if (-not (Test-Path $helmetAbs)) {
|
||||||
|
Write-Error "helmet.mdl not found: $helmetAbs`nRun from the Sven Co-op root folder."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||||
|
[Security.Principal.WindowsBuiltInRole]::Administrator
|
||||||
|
)
|
||||||
|
if (-not $isAdmin) {
|
||||||
|
$devMode = $false
|
||||||
|
try {
|
||||||
|
$dm = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" -ErrorAction SilentlyContinue
|
||||||
|
$devMode = $dm.AllowDevelopmentWithoutDevLicense -eq 1
|
||||||
|
} catch {}
|
||||||
|
if (-not $devMode) {
|
||||||
|
Write-Warning @"
|
||||||
|
Symlink creation may fail. To fix, either:
|
||||||
|
- Enable Developer Mode (Settings > For developers > Developer Mode)
|
||||||
|
- Re-run this script as Administrator
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Shared: symlink one model ID ---
|
||||||
|
function Invoke-Symlink {
|
||||||
|
param([string]$ModelId)
|
||||||
|
|
||||||
|
$modelDir = Join-Path $addonRoot $ModelId
|
||||||
|
$mdlPath = Join-Path $modelDir "$ModelId.mdl"
|
||||||
|
|
||||||
|
if (-not (Test-Path $modelDir -PathType Container)) {
|
||||||
|
if (-not $DryRun) { New-Item -ItemType Directory -Path $modelDir -Force | Out-Null }
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = Get-Item $mdlPath -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($item -and $item.LinkType) {
|
||||||
|
if ($item.Target -eq $symlinkTarget) { return "skipped" }
|
||||||
|
if (-not $DryRun) { Remove-Item $mdlPath -Force }
|
||||||
|
Write-Host " replace : $ModelId"
|
||||||
|
$result = "replaced"
|
||||||
|
} elseif ($item) {
|
||||||
|
if (-not $DryRun) { Remove-Item $mdlPath -Force }
|
||||||
|
Write-Host " replace : $ModelId (was real file)"
|
||||||
|
$result = "replaced"
|
||||||
|
} else {
|
||||||
|
Write-Host " create : $ModelId"
|
||||||
|
$result = "created"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $DryRun) {
|
||||||
|
try {
|
||||||
|
New-Item -ItemType SymbolicLink -Path $mdlPath -Target $symlinkTarget | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR : $ModelId -- $_"
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Summary {
|
||||||
|
param([hashtable]$Counts)
|
||||||
|
$total = $Counts.created + $Counts.replaced
|
||||||
|
$prefix = if ($DryRun) { "[DRY RUN] " } else { "" }
|
||||||
|
$verb = if ($DryRun) { "would be written" } else { "written" }
|
||||||
|
$errors = if ($Counts.error) { ", $($Counts.error) errors" } else { "" }
|
||||||
|
Write-Host "`n${prefix}$total symlinks $verb ($($Counts.created) new, $($Counts.replaced) replaced), $($Counts.skipped) already correct${errors}"
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = @{ created = 0; replaced = 0; skipped = 0; error = 0 }
|
||||||
|
|
||||||
|
# --- -Server: scan a player/ directory ---
|
||||||
|
if ($Server) {
|
||||||
|
if (-not (Test-Path $Server -PathType Container)) {
|
||||||
|
Write-Error "Server path not found: $Server"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$modelIds = Get-ChildItem -Path $Server -Recurse -Filter "*.mdl" |
|
||||||
|
Where-Object { $_.DirectoryName -ne $Server } |
|
||||||
|
ForEach-Object { $_.BaseName } |
|
||||||
|
Sort-Object -Unique
|
||||||
|
Write-Host "`n[server] -- $($modelIds.Count) models found in $Server`n"
|
||||||
|
foreach ($id in $modelIds) { $counts[(Invoke-Symlink $id)]++ }
|
||||||
|
Write-Summary $counts
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- -FromFile: read model IDs from a text file ---
|
||||||
|
if ($FromFile) {
|
||||||
|
if (-not (Test-Path $FromFile)) {
|
||||||
|
Write-Error "File not found: $FromFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$modelIds = Get-Content $FromFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
|
||||||
|
Write-Host "`n[from file] -- $($modelIds.Count) models in $FromFile`n"
|
||||||
|
foreach ($id in $modelIds) { $counts[(Invoke-Symlink $id)]++ }
|
||||||
|
Write-Summary $counts
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Default: tags.json ---
|
||||||
|
$tagsPath = Join-Path $scriptDir "tags.json"
|
||||||
|
if (-not (Test-Path $tagsPath)) {
|
||||||
|
Write-Error "tags.json not found next to script ($tagsPath)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = Get-Content $tagsPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
foreach ($category in $tags.PSObject.Properties) {
|
||||||
|
Write-Host "`n[$($category.Name)] -- $($category.Value.Count) models"
|
||||||
|
foreach ($id in $category.Value) { $counts[(Invoke-Symlink $id)]++ }
|
||||||
|
}
|
||||||
|
Write-Summary $counts
|
||||||
Executable
+155
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
symlink_models.py — place this file and tags.json in the "Sven Co-op" root folder.
|
||||||
|
|
||||||
|
Creates a relative symlink for every model:
|
||||||
|
svencoop_addon/models/player/<id>/<id>.mdl -> ../../../../svencoop/models/player/helmet/helmet.mdl
|
||||||
|
|
||||||
|
Sources (pick one, or combine):
|
||||||
|
default — model IDs from tags.json, grouped by category
|
||||||
|
--server PATH — scan any svencoop_addon/models/player/ directory for .mdl files
|
||||||
|
--from-file FILE — read one model ID per line from a text file
|
||||||
|
|
||||||
|
Windows: requires Developer Mode or Administrator privileges to create symlinks.
|
||||||
|
|
||||||
|
Dump server model IDs to a file (run on the server or over a mount):
|
||||||
|
find /home/svenserver/serverfiles/svencoop_addon/models/player \\
|
||||||
|
-mindepth 2 -maxdepth 2 -name "*.mdl" -printf "%f\\n" | sed 's/\\.mdl$//' > server_models.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HELMET_REL = Path("svencoop") / "models" / "player" / "helmet" / "helmet.mdl"
|
||||||
|
MODELS_REL = Path("svencoop_addon") / "models" / "player"
|
||||||
|
SYMLINK_TARGET = Path("..") / ".." / ".." / ".." / HELMET_REL
|
||||||
|
|
||||||
|
|
||||||
|
def check_windows_symlink_privilege():
|
||||||
|
if os.name != "nt":
|
||||||
|
return True
|
||||||
|
import ctypes
|
||||||
|
try:
|
||||||
|
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def symlink_one(model_id: str, models_root: Path, target_str: str, dry_run: bool) -> str:
|
||||||
|
"""Create/update symlink for one model_id. Returns 'created', 'replaced', 'skipped', 'error'."""
|
||||||
|
model_dir = models_root / model_id
|
||||||
|
mdl_path = model_dir / f"{model_id}.mdl"
|
||||||
|
|
||||||
|
if not model_dir.is_dir() and not dry_run:
|
||||||
|
model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if mdl_path.is_symlink():
|
||||||
|
if os.readlink(mdl_path) == target_str:
|
||||||
|
return "skipped"
|
||||||
|
if not dry_run:
|
||||||
|
mdl_path.unlink()
|
||||||
|
print(f" replace : {model_id}")
|
||||||
|
return "replaced"
|
||||||
|
elif mdl_path.exists():
|
||||||
|
if not dry_run:
|
||||||
|
mdl_path.unlink()
|
||||||
|
print(f" replace : {model_id} (was real file)")
|
||||||
|
return "replaced"
|
||||||
|
else:
|
||||||
|
print(f" create : {model_id}")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
os.symlink(target_str, mdl_path)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f" ERROR : {model_id} — {exc}")
|
||||||
|
return "error"
|
||||||
|
return "created"
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(counts: dict, dry_run: bool):
|
||||||
|
total = counts["created"] + counts["replaced"]
|
||||||
|
prefix = "[DRY RUN] " if dry_run else ""
|
||||||
|
verb = "would be written" if dry_run else "written"
|
||||||
|
print(
|
||||||
|
f"\n{prefix}{total} symlinks {verb} "
|
||||||
|
f"({counts['created']} new, {counts['replaced']} replaced), "
|
||||||
|
f"{counts['skipped']} already correct"
|
||||||
|
+ (f", {counts['error']} errors" if counts["error"] else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Symlink Sven Co-op player models to helmet.mdl")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would happen without making changes")
|
||||||
|
parser.add_argument("--server", metavar="PATH", help="Scan a server's svencoop_addon/models/player/ directory")
|
||||||
|
parser.add_argument("--from-file", metavar="FILE", help="Read model IDs (one per line) from a text file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
helmet_abs = script_dir / HELMET_REL
|
||||||
|
|
||||||
|
if not helmet_abs.exists():
|
||||||
|
sys.exit(f"helmet.mdl not found at {helmet_abs}\nAre you running from the Sven Co-op root?")
|
||||||
|
|
||||||
|
if os.name == "nt" and not check_windows_symlink_privilege():
|
||||||
|
print(
|
||||||
|
"WARNING: On Windows, symlink creation requires either:\n"
|
||||||
|
" • Developer Mode enabled (Settings > For developers)\n"
|
||||||
|
" • Running this script as Administrator\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
models_root = script_dir / MODELS_REL
|
||||||
|
target_str = str(SYMLINK_TARGET)
|
||||||
|
counts = {"created": 0, "replaced": 0, "skipped": 0, "error": 0}
|
||||||
|
|
||||||
|
# --- --server: scan a player/ directory for all .mdl files ---
|
||||||
|
if args.server:
|
||||||
|
server_root = Path(args.server)
|
||||||
|
if not server_root.is_dir():
|
||||||
|
sys.exit(f"Server path not found: {server_root}")
|
||||||
|
|
||||||
|
mdl_files = sorted(server_root.rglob("*.mdl"))
|
||||||
|
model_ids = sorted({f.stem for f in mdl_files if f.parent != server_root})
|
||||||
|
print(f"\n[server] — {len(model_ids)} models found in {server_root}\n")
|
||||||
|
for model_id in model_ids:
|
||||||
|
result = symlink_one(model_id, models_root, target_str, args.dry_run)
|
||||||
|
counts[result] += 1
|
||||||
|
print_summary(counts, args.dry_run)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- --from-file: read model IDs from a text file ---
|
||||||
|
if args.from_file:
|
||||||
|
file_path = Path(args.from_file)
|
||||||
|
if not file_path.exists():
|
||||||
|
sys.exit(f"File not found: {file_path}")
|
||||||
|
model_ids = [line.strip() for line in file_path.read_text().splitlines() if line.strip()]
|
||||||
|
print(f"\n[from file] — {len(model_ids)} models in {file_path}\n")
|
||||||
|
for model_id in model_ids:
|
||||||
|
result = symlink_one(model_id, models_root, target_str, args.dry_run)
|
||||||
|
counts[result] += 1
|
||||||
|
print_summary(counts, args.dry_run)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- default: tags.json ---
|
||||||
|
tags_path = script_dir / "tags.json"
|
||||||
|
if not tags_path.exists():
|
||||||
|
sys.exit(f"tags.json not found next to script ({tags_path})")
|
||||||
|
|
||||||
|
with open(tags_path, encoding="utf-8") as f:
|
||||||
|
tags: dict[str, list[str]] = json.load(f)
|
||||||
|
|
||||||
|
for category, model_ids in tags.items():
|
||||||
|
print(f"\n[{category}] — {len(model_ids)} models")
|
||||||
|
for model_id in model_ids:
|
||||||
|
result = symlink_one(model_id, models_root, target_str, args.dry_run)
|
||||||
|
counts[result] += 1
|
||||||
|
|
||||||
|
print_summary(counts, args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user