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
2 changes: 1 addition & 1 deletion .claude/skills/run-monx/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: run-monx
description: Build, run, and drive MONx (the Tauri desktop Ironcore monster XML editor). Use when asked to start MONx, build the exe, screenshot its UI, open a workspace in it, exercise the editor, or verify a Rust backend change (monster.rs/otb.rs/items.rs/dat.rs) via the probe CLI examples.
description: Build, run, and drive MONx (the Tauri desktop OpenTibia monster editor). Use when asked to start MONx, build the exe, screenshot its UI, open a workspace in it, exercise the editor, or verify a Rust backend change (monster.rs/otb.rs/items.rs/dat.rs) via the probe CLI examples.
---

MONx is a Windows Tauri 2 desktop app (React/TS frontend + Rust backend in `src-tauri/`).
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/run-monx/driver.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ param(

# The default fixture workspace. `assets/` holds one folder per engine —
# Ironcore, Canary, BlackTek, Nostalrius, TVP — each with monsters/, items/,
# client/ and spells/; Ironcore is the default profile, so it is the default
# here. Pass another engine's folder to `start`/`open` to test against it.
# client/ and spells/. Ironcore's is the default here only because it is the
# fullest of them, not because MONx favours that engine. Pass another
# engine's folder to `start`/`open` to test against it.
[string]$Fixture = (Resolve-Path "$PSScriptRoot\..\..\..\assets\Ironcore" -ErrorAction SilentlyContinue),

# Fill the four slots through the folder pickers instead of taking the
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
1.
2.

**Which server?** (Ironcore / TFS / TheVioletProject / Nostalrius / Canary / BlackTek)
**Which server?** (TFS / Ironcore / TheVioletProject / Nostalrius / Canary / CrystalServer / BlackTek)

**MONx version** (titlebar, or `package.json`):

Expand Down
24 changes: 11 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,18 @@ path it runs against the committed fixtures, so a fresh clone can check itself b
`assets/` is populated:

```sh
cargo run --release --example probe_monster # fixtures/engines/ironcore/monsters
cargo run --release --example probe_monster # fixtures/engines/ironcore/monsters — the fullest fixture, not a default engine
cargo run --release --example probe_monster -- fixtures/engines/tvp/monster --engine tvp --mutate
```

Those are a smoke test, not coverage — see `src-tauri/fixtures/README.md`. The real gates
point at a server's own tree:

```sh
cargo run --release --example probe_monster -- ../assets/Ironcore/monsters # round-trip
cargo run --release --example probe_monster -- ../assets/Ironcore/monsters --canonical --mutate
cargo run --release --example probe_monster -- ../assets/Ironcore/monsters --lint
cargo run --release --example probe_monster -- ../assets/Ironcore/monsters --crud <scratch-dir>
cargo run --release --example probe_monster -- ../assets/TFS/monster # round-trip
cargo run --release --example probe_monster -- ../assets/TFS/monster --canonical --mutate
cargo run --release --example probe_monster -- ../assets/TFS/monster --lint
cargo run --release --example probe_monster -- ../assets/TFS/monster --crud <scratch-dir>
```

`--mutate` is the one that proves the writer is driven by the model rather than copying bytes: it edits several fields in every file, writes, re-reads, and checks the document that comes back is the one that went in. It also budgets the diff — a handful of field edits that rewrite more than 12 lines fail, because the writer is meant to splice. A change that *inserts or removes* a node moves every line under it and can never meet that budget, so it belongs in a pass of its own (see `voice_extras_survive`). Add `--verbose` to any of them to list every finding, `--items <dir>` to point loot resolution at a database that is not the monsters folder's sibling, and `--bands` to print the corpus's balance bands.
Expand All @@ -70,7 +70,7 @@ cargo run --release --example probe_monster -- data/monster \

`--format json|sarif` implies `--lint` and writes the report to `--out <path>`, or to stdout with the human summary moved to stderr so the stream stays pipeable. `--fail-on` takes a **set** of severities, not a threshold — the three are not a ranking, and `silent` is the one worth failing a build on even where warnings are tolerated. `--fail-on any` is all three; without the flag the lint pass never fails the run, which is what keeps `--lint` usable on a corpus that has lived with three hundred warnings for years.

`--engine <key>` picks the profile (`ironcore`, `tfs`, `tvp`, `nostalrius`, `canary`, `crystal`, `blacktek`); without it the corpus is sniffed exactly as the Landing dialog sniffs it, and the guess is printed. **Run every gate against all seven engines' own corpora when touching the reader, writer or a profile** — an over-declared `known_attrs` drops data, and `--mutate` is the only thing that catches it:
`--engine <key>` picks the profile (`tfs`, `ironcore`, `tvp`, `nostalrius`, `canary`, `crystal`, `blacktek`); without it the corpus is sniffed exactly as the Landing dialog sniffs it, and the guess is printed. **Run every gate against all seven engines' own corpora when touching the reader, writer or a profile** — an over-declared `known_attrs` drops data, and `--mutate` is the only thing that catches it:

```sh
cargo run --release --example probe_monster -- ../assets/TVP/monster --engine tvp --mutate
Expand Down Expand Up @@ -256,16 +256,14 @@ assets/ fixture workspaces, one folder per engine: each has

## Domain knowledge

**Do not infer behaviour from upstream TFS.** Ironcore diverges in ways that matter constantly: per-spell cooldowns, extra flags, the pacifist system, `force` on summons, `corpseactionid`, `masterEffect`.

MONx also opens **TheForgottenServer 1.x, TheVioletProject, Nostalrius, Canary/OTServBR, CrystalServer and BlackTek** corpora. Everything below describes Ironcore, which is the default profile; what the other six do differently lives in `engine/profiles.rs` and is summarised in [ENGINES.md](ENGINES.md). Four consequences worth knowing before touching anything:
**Do not infer one engine's behaviour from another's.** MONx opens **TheForgottenServer 1.x, Ironcore, TheVioletProject, Nostalrius, Canary/OTServBR, CrystalServer and BlackTek** corpora, and they diverge in ways that matter constantly — per-spell cooldowns, extra flags, the pacifist system, `force` on summons, `corpseactionid`, `masterEffect` are Ironcore's; every other engine has its own list. None of them is the engine MONx is *for*: what each one does lives in `engine/profiles.rs` and is summarised in [ENGINES.md](ENGINES.md). Four consequences worth knowing before touching anything:

- **The reader, writer and linter all take a `&'static EngineProfile`.** There is one `MonsterDoc` for all seven engines — a superset — and the profile decides which parts the reader populates and the writer emits. Never hard-code a spelling like `raceid` or `CONST_ME_*`; ask the profile.
- **Two formats, one model.** Canary and BlackTek define monsters as Lua tables, not XML. `Parsed` is an enum with an XML body and a Lua body; `read_bytes`/`write_bytes` dispatch on `profile.format`. Everything above the document layer — `MonsterDoc`, the lints, the editor — is shared, and should stay that way.
- **A corpus can be a tree.** Only Ironcore is flat. A monster's key is its path relative to the monsters folder (`monsters/demon.xml`), matching its `file=` in `monsters.xml`.
- **A lint the engine has no rule for is suppressed, not reported.** `silent` severity is only worth anything if it means the server really would say nothing; firing Ironcore's rules at a TVP corpus inverts that. Per-engine suppressions live on the profile.
- **A corpus can be a tree.** Only Ironcore's is flat. A monster's key is its path relative to the monsters folder (`monsters/demon.xml`), matching its `file=` in `monsters.xml`.
- **A lint the engine has no rule for is suppressed, not reported.** `silent` severity is only worth anything if it means the server really would say nothing; firing one engine's rules at another's corpus inverts that. Per-engine suppressions live on the profile.

The format was originally specified in `MONSTER_EDITOR_REFERENCE.md` and the product in `DESIGN.md`; both were derived from the server's own source and have since been removed from the repo. The `§n` markers throughout the code cite them. What they said now lives in the code, and that is where to look — or to add to:
The format was originally specified in `MONSTER_EDITOR_REFERENCE.md` and the product in `DESIGN.md`; both were derived from Ironcore's own source, back when that was the only engine MONx read, and have since been removed from the repo. The `§n` markers throughout the code cite them. What they said now lives in the code, and that is where to look — or to add to:

- `catalog.rs` / `catalog.ts` — the enum tables (flags, damage and condition types, races, skulls, `CONST_ME_*`, `CONST_ANI_*`, built-in spells), each citing its section. The two are hand-kept mirrors and **`bun run catalog` is what keeps them honest** — add a value to one side only and nothing breaks, no build fails and no probe notices: the linter quietly stops calling it unknown while the picker still will not offer it, which is the "renders as nothing, deleted on the next click" case above. The check compares wire-exact names and numbers only; labels, colours, notes and group headings are UI-only and diverge on purpose. Where the two sides model the same fact differently — the unreachable shoot effects are a Rust exclusion list and a TS row flag — the check knows, and the comments in `scripts/check-catalog.mjs` say why for each.
- `customeffects.ts` / `engine/custom.rs` — the escape hatch for the tables above. Every effect table is read out of a shipped server's source, which is what makes it trustworthy and what makes it wrong for anyone who modified their own. A user declares the extras (Preferences → Custom effects, `monx.customEffects`), the picker appends them to the engine's list, and the linter stops calling them unknown. **The probes deliberately pass `CustomEffects::default()`** — a gate a setting can quieten is not a gate. Effects that are neither shipped nor declared still show in the picker as off-catalogue rather than reading as `(none)`, because a value the editor renders as "nothing" is a value the next click deletes.
Expand All @@ -277,7 +275,7 @@ The format was originally specified in `MONSTER_EDITOR_REFERENCE.md` and the pro
Four rules that come up constantly:

- **Round-trip is sacred.** Unknown attributes and comments are preserved verbatim; nothing is reordered or normalised on save. A value the engine would clamp gets linted, not silently rewritten.
- **Exact casing on the wire.** `raceid`, `maxSummons`, `actionId`, and upper-case `CONST_ME_*` / `CONST_ANI_*` — under Ironcore. Which spelling is the live one is a property of the engine: TFS reads `raceId` and names effects `firearea`, so both come from the profile rather than a literal.
- **Exact casing on the wire.** Which spelling is the live one is a property of the engine, never a literal: Ironcore reads `raceid` and upper-case `CONST_ME_*` / `CONST_ANI_*`, TFS reads `raceId` and names effects `firearea`. Both come from the profile.
- **MONx never invents item ids.** A loot id the items database cannot resolve is a lint (`loot.unknown-id`), not something to create. The check is against the database, not the OTB — most engines ship no OTB, and it is skipped entirely when no database was loaded.
- **`silent` is the loudest severity, not the quietest.** It marks the findings the server never reports at all — a spell the loader drops without a word. Those are the ones a human cannot discover any other way, so the UI gives them their own icon and hue rather than burying them under errors.

Expand Down
39 changes: 21 additions & 18 deletions ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ dead on Canary.

| Key | Server | Format | Shape |
|-----|--------|--------|-------|
| `ironcore` | Ironcore | XML | The default. `raceid`, `species`, the pacifist system, `CONST_ME_*` effects |
| `tfs` | TheForgottenServer 1.x | XML | `raceId` + `<bestiary>`, short-name effects, no pacifist system |
| `ironcore` | Ironcore | XML | `raceid`, `species`, the pacifist system, `CONST_ME_*` effects |
| `tvp` | TheVioletProject | XML | 7.x: `<targetstrategy>`, `delay=`, melee skill progression |
| `nostalrius` | Nostalrius | XML | 7.x: melee on `<attacks>`, no spell interval, `count=` conditions |
| `canary` | Canary / OTServBR | Lua | Bestiary + bosstiary, factions, `COMBAT_*` damage types |
Expand All @@ -64,7 +64,7 @@ codebase in half.

### A corpus can be a tree

Only Ironcore is flat. Everywhere else a monster's key is its path relative to
Only Ironcore's corpus is flat. Everywhere else a monster's key is its path relative to
the monsters folder (`monsters/demon.xml`), matching its `file=` in
`monsters.xml`.

Expand All @@ -79,13 +79,13 @@ The profile has ~50 fields. These are the ones that bite.

### Identity

| | ironcore | tfs | tvp | nostalrius | canary | crystal | blacktek |
| | tfs | ironcore | tvp | nostalrius | canary | crystal | blacktek |
|---|---|---|---|---|---|---|---|
| Race id attribute | `raceid` | `raceId` | — | — | — | — | — |
| `species=` | | | — | — | — | — | — |
| Bestiary | | | — | — | ✓ | ✓ | — |
| Race id attribute | `raceId` | `raceid` | — | — | — | — | — |
| `species=` | | | — | — | — | — | — |
| Bestiary | | | — | — | ✓ | ✓ | — |
| Registry (`monsters.xml`) | ✓ | ✓ | ✓ | ✓ | — | — | — |
| Recursive corpus | | | ✓ | ✓ | ✓ | ✓ | ✓ |
| Recursive corpus | | | ✓ | ✓ | ✓ | ✓ | ✓ |
| Extension | `.xml` | `.xml` | `.xml` | `.xml` | `.lua` | `.lua` | `.lua` |

Ironcore's `species=` is **author metadata and nothing else** — `monsters.cpp`
Expand All @@ -97,7 +97,7 @@ in detection.

### Spells

| | ironcore | tfs | tvp | nostalrius | canary | crystal | blacktek |
| | tfs | ironcore | tvp | nostalrius | canary | crystal | blacktek |
|---|---|---|---|---|---|---|---|
| Cadence | `interval` | `interval` | `interval`/`delay` | chance only | `interval` | `interval` | `interval` |
| Melee from | spell block | spell block | spell block | `<attacks>` node | spell block | spell block | spell block |
Expand All @@ -121,13 +121,13 @@ why they are enums and not booleans:

### Flags and resistances

| | ironcore | tfs | tvp | nostalrius | canary | crystal | blacktek |
| | tfs | ironcore | tvp | nostalrius | canary | crystal | blacktek |
|---|---|---|---|---|---|---|---|
| Pacifist system | | | — | — | — | — | — |
| Pacifist system | | | — | — | — | — | — |
| `canpushcreatures` override | always | always | **never** | always | never | never | **only when unset** |
| Clamps `health` to max | ✓ | ✓ | ✓ | **—** | ✓ | ✓ | ✓ |
| Effect naming | `CONST_ME_*` | short | short | short | `CONST_ME_*` | `CONST_ME_*` | `CONST_ME_*` |
| Effect matching | **case-sensitive** | lowercased | lowercased | lowercased | lowercased | lowercased | lowercased |
| Effect naming | short | `CONST_ME_*` | short | short | `CONST_ME_*` | `CONST_ME_*` | `CONST_ME_*` |
| Effect matching | lowercased | **case-sensitive** | lowercased | lowercased | lowercased | lowercased | lowercased |

`PushableOverride` needed three states rather than a bool because BlackTek
added a condition the C++ engines do not have: the override applies **only
Expand All @@ -141,11 +141,11 @@ Ironcore-only finding.

### Loot and summons

| | ironcore | tfs | tvp | nostalrius | canary | crystal | blacktek |
| | tfs | ironcore | tvp | nostalrius | canary | crystal | blacktek |
|---|---|---|---|---|---|---|---|
| Loot inside a wrapper node | ✓ | ✓ | ✓ | **—** | — | — | — |
| Loader validates loot ids | ✓ | ✓ | ✓ | **—** | ✓ | ✓ | ✓ |
| `countmax` ceiling | | | — | — | — | — | — |
| `countmax` ceiling | | | — | — | — | — | — |
| Summon `interval` | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
| Summon `delay` | — | — | ✓ | — | — | — | — |
| Summon cap key | `max` | `max` | `max` | `max` | **`count`** | **`count`** | `max` |
Expand All @@ -163,13 +163,16 @@ existing file is always mirrored rather than reshaped, because
> A lint the engine has no rule for is **suppressed, not reported**.

`silent` severity is only worth anything if it means the server really would
say nothing. Firing Ironcore's rules at a TVP corpus inverts that: it turns
"your server will not tell you about this" into noise.
say nothing. Firing one engine's rules at another's corpus inverts that: it
turns "your server will not tell you about this" into noise.

Each profile carries `suppressed_lints`, and an entry ending in `.` suppresses
the whole prefix:

- **Ironcore** — nothing suppressed. It is the reference engine.
- **Ironcore** — nothing suppressed, because every rule in `lint.rs` was
written against a real Ironcore consequence. That is a fact about where the
rules came from, not a ranking: a rule with no Ironcore counterpart would be
suppressed there like anywhere else.
- **TFS, TVP, Nostalrius** — the pacifist rules, since only Ironcore has that
system. TVP and Nostalrius additionally drop `flag.staticattack-over-100`.
- **Canary, Crystal, BlackTek** — `registry.` and (BlackTek) `raceid.`
Expand Down Expand Up @@ -216,7 +219,7 @@ Nothing else catches it:

```sh
cd src-tauri
cargo run --release --example probe_monster -- ../assets/Ironcore/monsters --mutate
cargo run --release --example probe_monster -- ../assets/TFS/monster --mutate
```

**Run every gate against all seven engines' own corpora when touching the
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

</div>

A monster editor for OpenTibia servers — Ironcore, TheForgottenServer, TheVioletProject, Nostalrius, Canary/OTServBR, CrystalServer and BlackTek. Open a workspace, pick a monster, edit, save.
A monster editor for OpenTibia servers — TheForgottenServer, Ironcore, TheVioletProject, Nostalrius, Canary/OTServBR, CrystalServer and BlackTek. Open a workspace, pick a monster, edit, save.

A workspace is up to four folders: the server's `monster/` folder, its `items/` folder, a client folder and optionally `spells/`. **Only the monsters folder is required** — the rest fill in what they can. Outfits, corpses and loot render as real sprites because the client assets are loaded alongside the monsters, from either a `.spr`/`.dat` pair or a modern 12.x+ asset bundle.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "monx",
"private": true,
"version": "0.1.131",
"version": "0.1.133",
"license": "MIT",
"type": "module",
"packageManager": "bun@1.3.14",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[package]
name = "monx"
version = "0.1.131"
description = "MONx — Ironcore monster XML editor"
authors = ["Ironcore"]
version = "0.1.133"
description = "MONx — OpenTibia monster editor"
authors = ["Coldensjo"]
edition = "2021"
license = "MIT"

Expand Down
5 changes: 5 additions & 0 deletions src-tauri/examples/probe_monster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ fn main() {
// The committed fixtures, so a fresh clone can run the gates without
// first populating the (gitignored) `assets/` workspaces. They are a
// smoke test, not coverage — see fixtures/README.md.
//
// Ironcore's is the one a bare run picks not because that engine is
// special but because its fixture is the fullest: it is the only corpus
// here with the pacifist voices, so `voice_extras_survive` has
// something to exercise. Name the folder to run any of the other six.
.unwrap_or_else(|| PathBuf::from("fixtures/engines/ironcore/monsters"));
// The CI surface. `--format` is what makes this usable as a check in a
// datapack repository: a machine document on stdout (or in a file), and an
Expand Down
Loading