Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 95 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,26 @@ restart your shell.
Upgrade after pulling changes: nothing to do (editable). To uninstall:
`uv tool uninstall fstools`.

### Shell completion

`fs` can complete subcommands, options, and file arguments. Install it once for
your shell, then restart the shell (or `source` your rc file):

```sh
fs --install-completion # detects your shell (bash/zsh/fish/PowerShell)
```

This appends a small snippet to your shell config (e.g. `~/.bashrc`,
`~/.zshrc`, `~/.config/fish/completions/`). Prefer to wire it up yourself?
`fs --show-completion` prints the script to stdout instead of installing it.

## Commands

```sh
cd MyMod && fs pack # pack the current folder -> ./FS25_MyMod.zip
fs pack -o build # -> build/FS25_MyMod.zip
fs pack -d # also copy into the FS25 mods folder
fs pack -p # deploy, then launch FS25 via Steam
fs pack --keep-images # keep ALL png/psd/tga (icon is kept regardless)
fs pack -n # dry run — list files, write nothing
fs pack ../OtherMod # a path still works if you'd rather not cd

Expand All @@ -37,6 +49,10 @@ fs testrunner # show installed TestRunner + whether a newer one exi
fs testrunner --update # install the newest TestRunner*.zip found
fs testrunner -s FILE.zip # install a specific TestRunner archive/exe

fs edit scene.i3d # open an .i3d in the GIANTS Editor
fs register-editor # make .i3d files open with the editor from any file manager
fs register-editor --remove # undo that association

fs log # follow log.txt live (errors red, warnings yellow)
fs log -e # only Error/Warning lines
fs log -n 50 # last 50 lines, then follow
Expand All @@ -57,14 +73,62 @@ lands at the zip root (what FS requires), using the stdlib `zipfile` — no
`7z`/`zip` binary needed. It refuses to pack a folder without a `modDesc.xml`.

Excluded by default: source/DCC files (`*.blend *.obj *.fbx *.mel *.mb *.ma`),
scripts (`*.cmd *.sh *.py`), docs (`*.txt *.md`), VCS/IDE dirs
(`.git .svn .idea .vscode …`), `$data` / `substance` folders, and image sources
(`*.png *.psd *.tga *.pdn *.gim`).
image-editor sources (`*.psd *.pdn`), scripts (`*.cmd *.sh *.py`), docs
(`*.txt *.md`), VCS/IDE dirs (`.git .svn .idea .vscode …`), and `$data` /
`substance` folders.

> Rendered images (`.png`, `.dds`, `.tga`, …) are **kept** — a mod's textures and
> icon ship as-is (only the editor sources `.psd`/`.pdn` are dropped). If your
> repo holds image (or other) files that aren't part of the mod, list them in a
> `.fsignore` (see below). Run `fs test` before publishing: the GIANTS TestRunner
> flags a `.png` used where a `.dds` is expected, so you don't need the packer to
> guess.

### Ignoring files (`.fsignore`)

Drop a `.fsignore` in the mod folder to keep repo-only files out of the zip. It's
gitignore-flavoured — one glob per line, `#` comments and blank lines ignored:

```gitignore
# working files that live in the repo but aren't part of the mod
*.psd
*.xcf
textures/_wip/ # a whole subfolder
docs/preview.png # a specific file
```

A pattern **without** a `/` matches that name at any depth (`*.psd`, `_wip/`); a
pattern **with** a `/` is anchored to the mod-relative path (`textures/_wip/`).
`*` spans path separators. The `.fsignore` itself is never packed.

### Per-mod config (`fstools.toml`)

> Images are stripped by default (matching the original packer) — **except the
> mod's icon**: the file named in `<iconFilename>` is always kept, even when it
> ships as a `.png` that `modDesc` references as `.dds` (FS converts it at load).
> Use `--keep-images` if a mod ships *other* textures as `.png` rather than `.dds`.
Drop an optional `fstools.toml` next to `modDesc.xml` to drive the packed mod's
name and metadata **without editing `modDesc.xml` or renaming the folder**:

```toml
[mod]
zip_name = "FS25_MyMod_dev" # packed .zip stem (FS25_ prefix added if missing)
version = "1.0.0.0-dev" # overrides <version>
author = "Me" # overrides <author>
title = "My Mod (dev)" # overrides every <title> language entry

# …or set titles per language instead of one string:
# [mod.title]
# en = "My Mod (dev)"
# de = "Mein Mod (dev)"
```

Every key is optional. `zip_name` changes only the output filename; `version`,
`author`, and `title` are rewritten **inside the packed zip's `modDesc.xml`** —
the file on disk is untouched, and `fstools.toml` itself is never packed. A
string `title` sets every existing `<title>` language entry; a `[mod.title]`
table sets (or creates) each language you list.

This lets a **dev build coexist with your stable mod in the same savegame**: give
it a different `zip_name` (FS keys mods by zip filename) and a `title` so you can
tell them apart in the mod list. Delete or `.gitignore` the file to go back to a
normal build. `fs test` honours the same config.

### ModHub TestRunner (`fs test`)

Expand Down Expand Up @@ -94,6 +158,27 @@ args shows the installed version and warns if a newer archive is available.
> results into one shared dir, only one `fs test` may run at a time — a second run
> refuses to start (lock) rather than corrupt results.

### Opening i3d files (`fs edit` / `fs register-editor`)

`fs edit scene.i3d` opens an `.i3d` scene in the GIANTS Editor. Like `fs test`,
the editor is a Windows program living inside the FS25 Proton prefix, so it's
launched via `protontricks-launch` with the file mapped to its `Z:\` prefix
path — no manual "add non-Steam game" dance. The editor is auto-detected under
`.../drive_c/Program Files/` (e.g. `GIANTS Software/GIANTS_Editor_10.0.11/editor.exe`,
newest version wins); set `FS25_EDITOR` if yours lives elsewhere. Pass `-v` to
run it in the foreground and see the editor's console (its "could not load
file …" warnings are handy for spotting missing or mis-cased references).

`fs register-editor` wires `.i3d` into your desktop so you can **double-click an
`.i3d` in any file manager** (or run `xdg-open scene.i3d`) and it opens in the
editor. It installs a freedesktop desktop entry (`~/.local/share/applications/`)
and a `application/x-i3d` MIME type for `*.i3d` (`~/.local/share/mime/`), then
makes it the default handler. `fs register-editor --remove` undoes both. Run it
once; it points at the `fs` on your PATH, so editable source changes keep working.

Requirements: `protontricks` (`protontricks-launch` on PATH) and the GIANTS
Editor installed in the FS25 prefix — the same setup `fs test` needs.

## Configuration

Paths auto-detect for Steam app `2300320`. Override via env vars:
Expand All @@ -105,6 +190,7 @@ Paths auto-detect for Steam app `2300320`. Override via env vars:
| `FS25_APPID` | Steam app id (default `2300320`) |
| `FS25_LOG` | path to `log.txt` (else auto-detected) |
| `FS25_TESTRUNNER`| path to `TestRunner_public.exe` |
| `FS25_EDITOR` | path to the GIANTS Editor `editor.exe` |

## Project layout

Expand All @@ -116,6 +202,7 @@ src/fstools/
pack.py # zip packing + exclusion rules
validate.py # modDesc.xml checks
testrunner.py # GIANTS ModHub TestRunner runner (via Proton)
editor.py # open .i3d in the GIANTS Editor + .i3d file association
logtail.py # log.txt follower
console.py # coloured output helpers
```
97 changes: 82 additions & 15 deletions src/fstools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
fs pack MOD pack a mod folder into a .zip (optionally deploy + launch)
fs log follow the game log.txt (errors in red, warnings yellow)
fs validate MOD sanity-check a mod's modDesc.xml
fs edit FILE.i3d open an .i3d scene in the GIANTS Editor
fs register-editor associate .i3d files with the GIANTS Editor
fs paths show the detected FS25 folders
"""
from __future__ import annotations

import contextlib
import shutil
import subprocess
import tempfile
Expand All @@ -16,13 +19,15 @@

import typer

from . import config, logtail, pack as packmod, testrunner, validate as validatemod
from . import (
config, editor as editormod, logtail, pack as packmod, packconfig,
testrunner, validate as validatemod,
)
from .console import die, info, ok, warn

app = typer.Typer(
help="Farming Simulator 25 modding helpers.",
no_args_is_help=True,
add_completion=False,
)


Expand All @@ -36,8 +41,6 @@ def pack(
False, "-d", "--deploy", help="also copy the .zip into the FS25 mods folder"),
play: bool = typer.Option(
False, "-p", "--play", help="deploy, then launch FS25 via Steam (implies --deploy)"),
keep_images: bool = typer.Option(
False, "--keep-images", help="do NOT strip png/psd/tga/pdn/gim"),
dry_run: bool = typer.Option(
False, "-n", "--dry-run", help="show what would happen without writing anything"),
) -> None:
Expand All @@ -48,16 +51,28 @@ def pack(
if not (mod_dir / "modDesc.xml").is_file():
raise die(f"'{mod_dir.name}' has no modDesc.xml — packing canceled!")

try:
cfg = packconfig.load(mod_dir)
except packconfig.PackConfigError as exc:
raise die(str(exc))

out_dir = output.expanduser().resolve() if output else mod_dir
zip_path = out_dir / f"{packmod.zip_stem(mod_dir)}.zip"
zip_path = out_dir / f"{packmod.zip_stem(mod_dir, cfg.zip_name)}.zip"

info(f"Packing mod {typer.style(mod_dir.name, bold=True)}")
info(f" source : {mod_dir}")
info(f" output : {zip_path}")
if keep_images:
info(" images : included (--keep-images)")

entries = packmod.collect_files(mod_dir, zip_path.name, keep_images)
src = f"(from {packconfig.CONFIG_NAME})"
if cfg.zip_name:
info(f" zip : {zip_path.stem} {src}")
if cfg.title:
info(f" title : {cfg.title_display()} {src}")
if cfg.version:
info(f" version: {cfg.version} {src}")
if cfg.author:
info(f" author : {cfg.author} {src}")

entries = packmod.collect_files(mod_dir, zip_path.name)

if dry_run:
warn(f"dry-run: would pack {len(entries)} files:")
Expand All @@ -66,7 +81,8 @@ def pack(
else:
if zip_path.exists():
warn(f"Old {zip_path.name} will be replaced")
size = packmod.write_zip(mod_dir, zip_path, entries)
size = packmod.write_zip(mod_dir, zip_path, entries,
title=cfg.title, version=cfg.version, author=cfg.author)
ok(f"Mod '{mod_dir.name}' packed successfully! "
f"({len(entries)} files, {size / 1_048_576:.1f} MiB)")

Expand Down Expand Up @@ -138,8 +154,6 @@ def validate(
def test(
mod: Path = typer.Argument(
Path("."), help="mod folder or .zip to test (default: current folder)"),
keep_images: bool = typer.Option(
False, "--keep-images", help="when packing a folder, keep png/psd/tga"),
verbose: bool = typer.Option(
False, "--verbose", help="pass --verbose to the TestRunner"),
) -> None:
Expand All @@ -166,10 +180,15 @@ def test(
if mod.is_dir():
if not (mod / "modDesc.xml").is_file():
raise die(f"'{mod.name}' has no modDesc.xml")
try:
cfg = packconfig.load(mod)
except packconfig.PackConfigError as exc:
raise die(str(exc))
tmp = tempfile.TemporaryDirectory(prefix="fstest-")
zip_path = Path(tmp.name) / f"{packmod.zip_stem(mod)}.zip"
entries = packmod.collect_files(mod, zip_path.name, keep_images)
packmod.write_zip(mod, zip_path, entries)
zip_path = Path(tmp.name) / f"{packmod.zip_stem(mod, cfg.zip_name)}.zip"
entries = packmod.collect_files(mod, zip_path.name)
packmod.write_zip(mod, zip_path, entries,
title=cfg.title, version=cfg.version, author=cfg.author)
info(f"Packed {mod.name} -> {zip_path.name} ({len(entries)} files)")
output_dir = mod.parent
elif mod.suffix.lower() == ".zip" and mod.is_file():
Expand Down Expand Up @@ -244,12 +263,60 @@ def testrunner_cmd(
ok(f"TestRunner updated -> {testrunner.INSTALL_DIR / testrunner.EXE_NAME}")


@app.command()
def edit(
scene: Path = typer.Argument(
..., help="the .i3d scene to open in the GIANTS Editor"),
debug: bool = typer.Option(
False, "-v", "--debug", help="run in the foreground and print the editor's "
"console (shows 'could not load file' warnings for missing/mis-cased refs)"),
) -> None:
"""Open an .i3d scene in the GIANTS Editor (via the FS25 Proton prefix)."""
path = scene.expanduser().resolve()
if not path.is_file():
raise die(f"File not found: {scene}")
if path.suffix.lower() != ".i3d":
warn(f"{path.name} is not an .i3d file — opening it anyway")
# In debug mode the editor inherits our stdio; Wine leaves the tty in raw
# mode on exit, so snapshot/restore the terminal around the whole run.
with (editormod.preserve_terminal() if debug else contextlib.nullcontext()):
try:
proc = editormod.launch(path, quiet=not debug)
except FileNotFoundError as exc:
raise die(str(exc))
ok(f"Opening {path.name} in the GIANTS Editor…")
if debug:
info("Editor console follows (close the editor to return):")
proc.wait()


@app.command("register-editor")
def register_editor(
remove: bool = typer.Option(
False, "--remove", help="undo the association instead of installing it"),
) -> None:
"""Associate .i3d files with the GIANTS Editor so you can open them from any
file manager (double-click) or with `xdg-open FILE.i3d`."""
if remove:
editormod.unregister()
ok("Removed the .i3d → GIANTS Editor file association.")
return
if config.editor_exe() is None:
warn("GIANTS Editor not detected in the FS25 prefix — installing the "
"association anyway. Set FS25_EDITOR or install the editor in the "
"prefix so `fs edit` can find editor.exe.")
editormod.register()
ok("Registered .i3d files to open with the GIANTS Editor (via `fs edit`).")
info("Double-click any .i3d in your file manager, or run: xdg-open FILE.i3d")


@app.command()
def paths() -> None:
"""Show the detected FS25 folders (useful for debugging config)."""
info(f"app id : {config.APPID}")
info(f"game data : {config.game_data_dir() or '(not found)'}")
info(f"game install: {config.game_install_dir() or '(not found)'}")
info(f"editor : {config.editor_exe() or '(not found)'}")
info(f"mods dir : {config.mods_dir() or '(not found)'}")
info(f"log.txt : {config.log_path() or '(not found)'}")
info(f"testrunner : {testrunner.installed_exe() or '(not installed)'}")
Expand Down
Loading