# Python ZIP mods

Egregoria embeds RustPython 0.5 and loads trusted Python mods from validated ZIP archives. Mods execute during game startup before the main menu becomes interactive. A failed mod is isolated, recorded in the **Python Mods** page, and does not prevent the menu or other valid mods from loading.

> **Security:** Python mods are trusted native-equivalent code. The embedded runtime is deliberately small, but it is not a security sandbox. Install only archives you trust.

## Install and enable

1. Open **Main Menu → Python Mods → Import ZIP** on Android and choose the archive through the system document picker. Egregoria copies the selected document into app-private `files/mods/`, validates it, and leaves it disabled. No broad storage permission is requested.
   - Desktop: put the ZIP in `mods/` under Egregoria's working/data directory, then choose **Refresh ZIPs**.
   - Android developers can still stream a fixture directly into a debuggable build:
     ```bash
     adb shell run-as org.egregoria.android sh -c \
       'cat > files/mods/my-mod.zip' < my-mod.zip
     ```
2. Enable the mod in **Python Mods**.
3. Restart Egregoria. Newly imported archives are disabled by default and are never executed merely because they were selected.

Enablement is bound to the ZIP's SHA-256, not only its manifest ID. Replacing an archive with different bytes disables it until the user explicitly approves that exact archive. Older ID-only preferences are migrated conservatively as disabled and require a one-time re-enable.

## Archive format

`mod.json` may be at the ZIP root or inside exactly one top-level folder. The entrypoint and declared assets are relative to that manifest.

```json
{
  "api": 1,
  "id": "example-mod",
  "name": "Example Mod",
  "version": "1.0.0",
  "compatible_game_version": "*",
  "description": "An example",
  "entrypoint": "main.py",
  "dependencies": [],
  "load_after": [],
  "load_before": [],
  "assets": []
}
```

- `api`: required mod API version; currently `1`.
- `id`: unique lowercase ASCII ID, 1–64 characters; letters, digits, `.`, `_`, and `-` only.
- `name`: display name, maximum 80 characters.
- `version`: mod version string, maximum 32 characters.
- `compatible_game_version`: `"*"` or an exact Egregoria version string.
- `entrypoint`: UTF-8 Python source; defaults to `main.py`.
- `dependencies`: required enabled mod IDs. Missing/failed dependencies isolate this mod.
- `load_after` / `load_before`: optional ordering edges when the referenced enabled mod exists.
- `assets`: optional declared relative file paths; every declaration must exist in the archive.

Ordering is a deterministic topological sort with mod ID as the tie-breaker. Dependency/order cycles are rejected and shown per mod.

## Python API version 1

The entrypoint must define:

```python
def on_load(context):
    context.log("loaded")
```

Available context members:

- `context.api_version`
- `context.mod_id`, `context.mod_name`, `context.mod_version`
- `context.game_version`
- `context.log(message)`
- `context.get_data(key, default=None)`
- `context.set_data(key, value)` — strings persisted per mod under `config/python_mod_state/`
- `context.add_money(amount)` — queues a signed whole-bucks delta, clamped by the host
- `context.set_time_warp(multiplier)` — accepts `0`, `1`, `2`, `4`, or `8`

The initial runtime is load-time only. It does not expose per-frame callbacks or arbitrary engine objects. RustPython is built without its standard library, so native CPython extension modules and packages expecting CPython are unsupported.

## Validation and cache limits

Before execution Egregoria rejects:

- absolute, drive-qualified, empty-component, `.`, or `..` paths;
- duplicate normalized paths and case-fold collisions;
- symlinks and non-regular special entries;
- encrypted/unsupported ZIP entries;
- archives over 16 MiB, over 256 entries, over 2 MiB per entry, or over 8 MiB total uncompressed;
- forged ZIP size metadata: limits are enforced again against actual decompressed bytes, and actual/declared sizes must match;
- paths over 240 characters or expansion ratios over 200:1;
- missing/ambiguous manifests, entrypoints, or declared assets.

A validated archive is first copied into a bounded app-private snapshot. Validation, SHA-256 calculation, and extraction all consume that same immutable snapshot, preventing archive-swap races. It is extracted to `config/python_mod_cache/<mod-id>/<sha256>/` through a same-filesystem temporary directory, then atomically promoted only after all files are written. Cache markers are versioned so caches produced by older, less strict extractors are rebuilt. A changed ZIP gets a new hash-keyed cache. Execution reads the validated cached entrypoint. Each mod has independent state and diagnostics.

## Samples

Checked-in source and ready-to-install ZIPs are under `native_app/sample_mods/`:

- `hello-egregoria.zip`: logging and persistent state.
- `starter-funds.zip`: dependency ordering and a one-time money action.
