Files
sortof/docs/specs/2026-04-30-patch-tier.md

88 lines
6.9 KiB
Markdown

# Spec G-patch - Patch tier (Final Loads)
**Date:** 2026-04-30
**Status:** Draft (awaiting review)
**Sibling specs:** A (multi-branch picker), B (collection expansion + live progress), C (build context), D (dep "Add" button), E (precacher), F (folded into B), G (cleanups bundle - this spec carves a piece out)
## 1. Summary
Add a **"patch" tier** to the load-order calculation: mods explicitly authored or detected as patches sort *after* every non-patch mod, including those flagged `loadLast=on`. Implementation is a single new axis at the top of `mlos_sort._initial_sort_key` plus a heuristic in `derive_category`. No schema migration. No new endpoint. No backwards-incompat changes for existing mods.
## 2. Problem
The PZ load-order convention (and the user-supplied 37-bucket taxonomy, bucket 37 "Final Loads") treats compatibility patches and retextures-of-other-mods as a strictly-last tier - they have to load *after* `loadLast=on` map mods, because they intercept or override the things those mods install. Today our sort key has no such tier:
```
PREORDER → loadFirst → loadLast → category → in-category loadFirst → in-category loadLast → alpha
```
A `loadLast=on` map mod ends up in the same bucket as a patch, ordered alphabetically. Patches that need to override the map mod can land *before* it. Silent corruption - output looks valid, the wrong mod wins at runtime.
## 3. Detection rules
A mod is a patch iff **any** of these is true:
1. **Explicit:** `mod.info` contains `category=patch` (new value added to `RAW_CATEGORY_ORDER`).
2. **Author-tagged via sorting_rules.txt:** user-supplied `[modId]\ncategory=patch` overrides anything else (existing mechanism, no change).
3. **Name heuristic (conservative):** `mod.name` matches the case-insensitive regex `\b(patch|compat|compatibility)\b`. Examples that match: `BetterFlashlight Patch`, `BB Compatibility`, `RavenCreek - MoreSimpleClothing Compat`. Examples that **do not** match: `BugFixes`, `LittleTweaks`, `BalanceFix` - "fix" / "tweak" / "fixes" are too broad and would over-flag.
The first matching rule wins. The heuristic is intentionally narrow; mod authors who want to opt in should use rule 1.
## 4. Sort behavior
Insert a new axis **at position 0** (above `PREORDER`) of the sort tuple:
```
(is_patch, PREORDER, loadFirst, loadLast, category, in-cat loadFirst, in-cat loadLast, alpha)
```
`is_patch = 1` for patches, `0` otherwise. Tuple comparison guarantees patches sort after every non-patch mod regardless of every downstream axis. Within the patch group, the existing sub-keys still apply (a patch with `PREORDER=2`, e.g. `ModManagerServer-Patch`, still sorts second-among-patches).
## 5. Backend changes
- **`mlos_sort.py`:**
- Append `"patch"` to `RAW_CATEGORY_ORDER` (so it's a valid `mod.category` value and topo sort treats it like any other category).
- Extend `derive_category(mod)` with the §3 name heuristic, returning `"patch"` when matched and category is otherwise `undefined`.
- Modify `_initial_sort_key`: prepend `1 if mod.category == "patch" else 0` as the new tuple element.
- **`adapters.py`:** extend `CAT_MAP` with `"patch": "patch"` so the frontend pill key is preserved (see §6).
- **`worker.py`:** no change. `mod.info` parsing already accepts arbitrary `category=…` values; once `"patch"` is in `CATEGORY_ORDER`, existing parser code passes it through unchanged.
- **No schema migration.** `mod_parsed.category` is already `TEXT NOT NULL DEFAULT 'undefined'` - `"patch"` fits without alteration.
## 6. Frontend changes
- **New pill** `patch` in the mod-table category column. Recommended palette: muted mauve / pale grey to distinguish from `gameplay` (the current default for tweaks-shaped mods) without competing for attention.
- **Pill is descriptive only** - sort position already telegraphs "this is a patch" since patches cluster at the bottom of the table. The pill is a quick visual confirmation, not a signal the user has to learn.
- **CSS** addition only (one rule): `.cat.patch { background: …; color: …; }`. No layout or component changes.
If the user prefers to skip the pill (5 buckets stays cleaner), the spec is satisfied without it; sort behavior is the load-bearing change.
## 7. Out of scope
- Detecting patches from Steam workshop tags (Steam's vocabulary has no canonical "Patch" tag - `Misc` and `Framework` are the closest, both too noisy to map).
- Multiple patch sub-tiers (e.g., "patches-of-patches"). YAGNI; the existing `loadAfter` mechanism handles ordering between two patches when needed.
- A `mod_parsed.is_patch` boolean column. Derived from `category` is sufficient and avoids a migration.
- Auto-detecting patches via mod content inspection (Lua module overrides, file collisions). Heuristics only.
## 8. Acceptance criteria
- [ ] `mlos_sort._initial_sort_key` returns an 8-element tuple with `is_patch` (0 or 1) at index 0.
- [ ] `RAW_CATEGORY_ORDER` includes `"patch"`.
- [ ] `derive_category` returns `"patch"` when the name regex `\b(patch|compat|compatibility)\b` (case-insensitive) matches and `mod.info`'s `category=` is unset or `undefined`.
- [ ] Explicit `category=patch` in `mod.info` is honored by the existing parser (no parser change required).
- [ ] `sorting_rules.txt` `category=patch` override forces a mod into the patch tier.
- [ ] In a sort with one `loadLast=on` map mod and one patch, the patch sorts *after* the map mod in `SORTED_ORDER`.
- [ ] In a sort with two patches, alphabetical ordering applies between them (existing alpha tiebreaker preserved).
- [ ] In a sort with no patches, `SORTED_ORDER` is bit-identical to pre-spec output (`is_patch=0` for all rows preserves existing total ordering).
- [ ] `MOD_DB` rows for patches carry `cat: "patch"` once `adapters.CAT_MAP` is extended.
## 9. Test cases
1. **Explicit patch via mod.info** - wsid X has `category=patch`. Expect: sorts last regardless of `loadLast`. `MOD_DB.cat = "patch"`.
2. **Heuristic match** - mod named `BB Compatibility Patch`, no explicit category. Expect: detected as patch, sorted last.
3. **Heuristic miss (intentional)** - mods named `BugFixes`, `LittleTweaks`, `BalanceFix`. Expect: NOT in patch tier.
4. **Patch + loadLast map mod** - input: a `loadLast=on` map mod (`Eerie_County`) and a patch (`Eerie_County - Brita Compat`). Expect: `Eerie_County` precedes the patch in `SORTED_ORDER`.
5. **Two patches** - `AAA-Compatibility` and `ZZZ-Patch`. Expect: alphabetical order preserved within the tier.
6. **No patches in input** - sort identical to current behavior; regression test against a saved canonical fixture (e.g. `2169435993;2392709985;2487022075`).
7. **`sorting_rules.txt` override** - user supplies `[Some_Mod]\ncategory=patch`; expected to force into tier even if name doesn't match heuristic and `mod.info` doesn't declare it.
8. **Patch with PREORDER mod_id** - hypothetical `ModManagerServer-Patch` (mod_id matches PREORDER table). Expect: still sorts within the patch tier (last), but among patches uses PREORDER=2 sub-ordering.