# The Compat Harness `Tools/run-compat-test.sh` is not a unit test. It is a **documented tool** that boots a *real, headless RimWorld* with the entire Institution suite and the most-subscribed relevant Workshop mods loaded **at once**, plays the game at maximum speed for a few thousand ticks, and greps the log for assertions that the mods it built actually work — and don't silently eat each other. Ward carries this harness for the whole suite because Ward is where the compatibility argument lives: Ward's central claim is "I add defs, I patch nothing, so I can't collide with your mod list," and a claim like that can only be *believed* from static analysis. It has to be *run* to be proven. --- ## Why a running game, not static analysis Static analysis got the design most of the way. Dumping the Harmony attributes out of every mod assembly shows **where** two mods want to patch the same vanilla method. What it cannot show is whether that collision actually **bites**: - Two mods can both postfix a method and be completely fine. - Or one mod's **prefix returns `false`**, which short-circuits the original method *and every other prefix* — silently eating another mod's patch. **Nothing crashes.** A feature just quietly stops working. The canonical example the harness guards: *Hospitality* prefixes `MentalStateHandler.TryStartMentalState`. If Ward ever added a second prefix there that returned `false`, Hospitality's would silently never run, and there would be no error to notice. So the harness doesn't read code — it interrogates the **live Harmony patch table inside a running RimWorld** and asserts who owns each contested method. The same failure mode, in a different shape, threatens Foul Play: the "Piss Nuke" psychotic-spree feature is delivered by **XPath PatchOperations** into `MentalStateNonCritical` (and the escape/duty defs). If another mod reshapes those defs, the XPath silently stops matching, the feature dies, and — again — nothing errors. Only booting the whole stack and watching the spree actually happen catches it. --- ## What it loads (~24 active mods) The full-stack run activates the base game + three DLC, two libraries, the popular psych/prison Workshop mods, and the whole Institution suite — **24 active mods** by default, **25** with the `FakeDLC` fixture switched on. | Group | Mods | |---|---| | Base + DLC | RimWorld Core, Royalty, Ideology, Biotech | | Libraries | Harmony, HugsLib | | Guest/prisoner rail | Hospitality, Prison Labor, Locks, Prison Commons, Custom Prisoner Interactions, Prisoner Realism, Prisoner Recreation | | Mental health | Psychology (unofficial), Rim Disorders, More Mental Breaks, Restraints | | The big neighbour | Dubs Bad Hygiene (632k subscribers — the one Foul Play deliberately does *not* duplicate) | | Institution suite | Core, Contraband, Justice, Gangs, Foul Play, **Ward** | | Optional fixture | FakeDLC (stands in for Anomaly/Odyssey; only with `FAKE_DLC=1`) | The Workshop mods are read from a persistent, ID-keyed cache (default `/home/dev/rimworld-ref/mods-cache`, keyed by Workshop file id). The cache lives on the home partition, not `/tmp`, because a scratchpad reap once took the whole stack down with it. Rebuild it from an installed run with `Tools/rebuild-mods-cache.sh`. Two sharp compat targets in that list are worth calling out, because they are exactly the ones that *could* break Ward: - **Prisoner Realism** postfixes `Verse.Room.IsPrisonCell` — and Ward introduces a room role that can outrank `PrisonCell`. Statically, `isPrisonCell` is a cached field written only by `Notify_RoomShapeChanged` and is **not** derived from the role, so Ward can't break it. The harness proves that at runtime, on a room it builds and re-roles live. - **Hospitality** owns the whole "a pawn in the colony who is not a colonist" surface, including that `TryStartMentalState` prefix. Ward rides the *prisoner* rail; Hospitality rides the *guest* rail; the harness asserts they never touch. --- ## The dependency-ordered build The Institution suite is split across separate assemblies with real cross-references, so they must **build** and **load** in dependency order or the references won't resolve. Core is the dependency-free leaf everything else reads. ### Build order (what the script compiles, and why) | # | Built | Depends on (to compile) | SelfTest? | Notes | |---|---|---|---|---| | 1 | **Institution: Core** | base game only | no | The propensity / criminal-record / secured-context engine. Built even in Core-only, because everything below references its DLL. | | 2 | **Institution: Justice** | Core | no | Classification, deterrence, discipline, parole, regime. Built even in Core-only (the bridge references it). | | 3 | **Institution: Contraband** | Core | no | Concealment, search, tunnels. Builds **clean** now — see "Where CBTEST lives" below. | | 4 | **Foul Play** (+ bridge) | Core, Contraband, its own DLL | **yes** (`PNTEST`) | The vessel/substance/throw framework and the Piss Nuke flagship. Its optional Contraband **bridge** is compiled against the Core/Contraband/FoulPlay DLLs. | | 5 | **Institution: Gangs** | Core, Contraband, Justice | **yes** (`CBTEST`) | The social capstone — the only assembly that references *every* other Institution mod, which is why the cross-module integration harness lives here. | | 6 | **Institution: Ward** | base game only (harness: + Harmony) | **separate assembly** (`WARDTEST`) | Ship `Ward.dll` carries no harness (Harmony ships as a normal dependency; the harness does not). The `WARDTEST` harness is its own project, `Source/Ward.CompatTest` → `Ward.CompatTest.dll`, built to a scratch dir and dropped beside the ship DLL at test time. | ### Load order (the `activeMods` list) Harmony first (it says so itself), then the base game + DLC, then HugsLib, then the Workshop mods, then the Institution suite **in dependency order**, and **Ward dead last**: ``` Core → Contraband → Justice → Foul Play → Gangs → Ward ``` Contraband and Justice each need only Core (their relative order doesn't matter). Foul Play's bridge needs Contraband **active**, so Foul Play loads after it. Gangs is the capstone and needs Core + Contraband + Justice. **Ward loads last on purpose** — its Harmony conflict report reads the live patch table, so every other mod must have had its chance to patch *before* Ward looks. The script self-checks the config, not just the mod: every Workshop mod copied into `Mods/` has its real `packageId` cross-checked against `activeMods`, because copying a mod in without activating it would silently shrink the stack and still pass. It also lints every def XML first (`Tools/lint-xml.py`) — a malformed comment makes RimWorld drop a def *silently*, and that's not worth a 15-minute headless run to discover. --- ## Where CBTEST lives now (Gangs, SelfTest) The suite's cross-module integration test — the `CBTEST` family, which drives the Prison-Architect tunnel dig→search→breach loop and the full Core/Justice/Gangs interaction — used to live in Contraband. It **moved into Gangs**, because Gangs is the one assembly that already references every Institution mod (Core, Contraband, Justice). Consequences: - **Contraband now builds clean** — no `SelfTest`, no test code in the shipped `Contraband.dll`. - **Gangs** is built with `-p:SelfTest=true`, which compiles its integration `GameComponent` in. That build leaves test code in `Assemblies/InstitutionGangs.dll`, so the Gangs repo must be rebuilt clean before its shipped DLL is committed. - **Foul Play** is likewise built `-p:SelfTest=true` for its own `PNTEST` harness, so its DLL must be rebuilt clean before it is shipped. - **Ward** no longer works that way. Its harness is a **separate assembly** (`Source/Ward.CompatTest` → `Ward.CompatTest.dll`), never compiled into `Ward.dll`. There is no `SelfTest` flag on the ship project and nothing to "rebuild clean" — a build of `Ward.csproj` is physically incapable of emitting the harness or a Harmony reference. `run-compat-test.sh` builds the harness to a scratch dir and installs `Ward.CompatTest.dll` beside the clean ship DLL, where RimWorld loads both. A CI guard additionally fails any push whose committed `Ward.dll` carries `HarmonyLib`/`CompatTest` symbols. (Gangs and Foul Play still rely on the rebuild-clean discipline; Ward removed that failure mode outright after a SelfTest `Ward.dll` shipped by accident.) --- ## The assertion families Everything is written to the log with a family tag, and the verdict section greps for each one. A run with no `WARDTEST` lines at all is a hard failure ("NO ASSERTIONS RAN"). | Tag | Owner | What it proves | |---|---|---| | **`WARDTEST`** | Ward (`Source/Ward.CompatTest/CompatTest.cs`) | Ward loaded, patches no *contested* method, and works on a live prisoner | | **`PNTEST`** | Foul Play self-test | The Piss Nuke / vessel / substance / ferment content works under the stack | | **`FPBRIDGE`** | Foul Play ↔ Contraband bridge | The optional bridge loaded (and *only* when Contraband is present) and merged the two frameworks | | **`CBTEST`** | Gangs self-test | The whole Institution loop — tunnel, search, classification, discipline, parole, corruption, gangs, regime — runs end-to-end on a live map | | *(`PNCOMBAT`)* | Foul Play | Grepped alongside PNTEST for the combat/throw path | ### `WARDTEST` — Ward's own checks Split between a `[StaticConstructorOnStartup]` (static def checks, "did Ward *load*") and a `GameComponent` that runs at tick 60+ (the live checks, because the static-ctor order between mods is undefined and Contraband injects its escape JobGivers from its *own* static ctor): - **Def load:** `def.interactionMode` (and `.nonExclusive`), `def.roomRole` (and `.workerResolves`), `def.workGiver` (and `.classResolves`), `def.job`, `def.hediff`, `def.station`, `station.graphicLoaded` (a missing texture is a magenta box and a log line, not a crash — so it's checked on purpose). - **Vanilla intact:** `vanilla.prisonerModes.intact` — `MaintainOnly`, `AttemptRecruit`, `ReduceResistance`, `Release`, `Execution` all still exist (a defName collision would let the game boot while quietly removing your ability to execute or release a prisoner). - **The central claim:** `ward.patchesNoContestedMethod` — the harness enumerates every vanilla method where a psych/prison mod *could* collide (`SetGuestStatus`, `IsValidBedFor`, `InAllowedArea`, `TryStartMentalState`, `MentalBreaker.TryDoRandomMoodCausedMentalBreak`, `WorkGiver_Warden.ShouldSkip`, `WorkGiver_Warden_Chat.JobOnThing`, …), reads the live Harmony patch owners, and asserts **none of them is owned by Ward.** The moment Ward grows a Harmony patch on a contested method, this fails. - **The specific one:** `ward.noPrefixOn.TryStartMentalState` — Ward must never add a *prefix* there, or it silently eats Hospitality's. - **Live usability:** `live.isPrisoner`, `live.psychCareEnabled`, `live.stacksWithRecruit` — a real prisoner is generated, flagged for care via vanilla's *own* public API, and confirmed to stack with `AttemptRecruit`, with Prison Labor / Custom Prisoner Interactions / Hospitality all loaded and patching the guest tracker. - **The built ward:** the component builds an actual walled, roofed room with a prisoner bed and a station on the live map, forces a region/room rebuild, and asserts `room.proper`, `room.plainCellNotStolen` (a bare cell keeps its vanilla role), `room.roleIsWard`, and — the load-bearing one — `room.stillPrisonCellAsWard`. - **Escape-tree order (`escape.armBeforeExit`, `escape.useContrabandBeforeExit`):** Ward also verifies that Contraband's escape JobGivers land *before* `JobGiver_GotoTravelDestination` in the `PrisonerEscape` think tree. A think node that *landed* is not a think node that can *run* — the tree takes the first node that returns a job, and the travel-destination node returns one essentially always, so anything appended after it is dead code that would pass a mere presence test. The harness asserts **position**, because pre-order index is evaluation order. - Terminated by `WARDTEST DONE`. ### `PNTEST` / `FPBRIDGE` — Foul Play The Foul Play self-test proves the flagship content survives the stack: the carboy weapon def loads (`def.weapon`), the spree think-tree XPath still matches (`thinktree.patched`), and the generic-vessel refactor holds — runtime substance, label-shows-contents, the eight-substance catalogue, drink consequences, mix ratios, pour capacity, water-douses-fire, blend round-trip through save/load, fermentation dilution and spoilage, "only 100% ripe piss reads as a nuke," any pawn filling a vessel, furniture dip/pour, and the autonomous cocktail on fill. The `FPBRIDGE` family fires only when Contraband is present, and proves the optional bridge wired the carboy's brew-from-body worker, merged the two propensity engines, and wired the content-tag delegates so a concealed vessel keeps its substance. ### `CBTEST` — the suite integration test (in Gangs) When Contraband is active, the Gangs self-test drives the entire loop on a live map and every one of these must report `True`: `escape.registered`, `escape.foundWillingDigger`, `escape.digRatePositive`, `escape.tickAdvancesTunnel`, `escape.recordsAttempt`, `escape.breachDestroysWall`, `escape.emergedOutside`, `escape.toolConsumed`, `intake.concealsOnBodyContraband`, `content.tagSurvivesRedeem`, `corruption.dispositionVaries`, `classification.gradeReflectsRecord`, `prisonization.reshapesNurture`, `justice.deterrenceFeedback`, `discipline.hardens`, `parole.releaseDecision`, `corruption.bentWardenSmuggles`, `gangs.smugglesAcrossWall`, `gangs.fightIsCrime`, `gangs.membershipByDisposition`, `regime.prisonerRecreation`. Any `CBTEST … = False`, or a `CBTEST HARNESS THREW`, fails the run. --- ## How to run it ```bash # Full stack: ~24 active mods, all DLC, the popular Workshop mods. Tools/run-compat-test.sh /path/to/rimworld-install [/path/to/workshop-mods] ``` The second argument (the Workshop-mod cache) defaults to `/home/dev/rimworld-ref/mods-cache` or `$RIMWORLD_MODS_CACHE`. The Institution sibling source dirs default to `$HOME/rimworld-` and can be overridden with `INSTITUTIONCORE`, `INSTITUTIONJUSTICE`, `CONTRABAND`, `FOULPLAY`, `INSTITUTIONGANGS`. ### The two modes — they fail in opposite directions | Env var | What it does | Why it exists | |---|---|---| | `CORE_ONLY=1` | No DLC, no Workshop mods, no Contraband/Justice/Gangs — just **Foul Play + Ward on a bare Core install** | Coexisting with 24 mods proves nothing about the player who owns no DLC. A def that accidentally referenced a DLC thing would resolve happily in the full run and break for everyone else. This is the opposite test. | | `FAKE_DLC=1` | Loads `Tools/fixtures/FakeDLC` — a *hostile* mod that does to Ward's surface everything an unknown DLC could: competing prisoner interaction modes, a competing room role (`~1e5 × beds`), and pokes the very think-tree/duty defs the Piss Nuke XPaths depend on | You own Royalty/Ideology/Biotech but **cannot** load Anomaly/Odyssey without owning them (a DLC's defs can't be downloaded otherwise). The fixture stands in for what can't be loaded, and it loads *before* the suite so its patches land first. | `CORE_ONLY` is not achievable by editing `ModsConfig` (RimWorld re-activates any expansion whose `Data/` folder is present) or by symlinks (Unity resolves the executable's real path back into the original install). The script builds a **hard-link copy** of the game with the DLC `Data/` folders deleted — the binary genuinely lives in a DLC-free tree, so its real path resolves there, at no disk cost. In Core-only, `CB_ACTIVE=0` drops Contraband/Justice/Gangs from the load order too, so the pass tests Foul Play + Ward genuinely standalone: the Contraband bridge must **not** load, yet the piss content must still work. The verdict section asserts both directions — and a Core-only run that quietly still had DLC loaded (which happened, twice, during development) is caught by grepping the `loaded mods` line for `royalty|ideology|biotech`. ### Watching a run ```bash # Stream a run to a stable log on the home partition (survives /tmp reaps): FAKE_DLC=1 Tools/harness-stream.sh /path/to/rimworld-install # Follow it live from anywhere on the box (-F survives the log being recreated each run): Tools/watch-harness.sh ``` Internally the run boots via `timeout 900 xvfb-run … RimWorldLinux -logfile … -quicktest`, forces `TimeSpeed.Ultrafast` from the `GameComponent` (same per-tick logic, just no idle real-time between ticks), and calls `Root.Shutdown()` once every assertion is written (~tick 8000; a 12000-tick watchdog is the safety cap). The 900-second `timeout` is a backstop, not the normal exit. --- ## Requirements — and why it's local, not CI | Requirement | Why | |---|---| | A **licensed RimWorld install** with the `RimWorldLinux` executable | The harness boots the *real* game engine. There is no headless stub — the whole value is that vanilla's own room grid, think trees, and mental-state handler run for real. | | **The Workshop mods**, in the ID-keyed cache | Fetched with `DepotDownloader -app 294100 -pubfile ` — which needs a Steam account that *owns* RimWorld. The mods can't be redistributed. | | **xvfb** + Mesa software GL (`libgl1-mesa-dri`) | RimWorld is a Unity app that insists on a GL context even for `-quicktest`. `xvfb-run` gives it a headless framebuffer; `LIBGL_ALWAYS_SOFTWARE=1` / `llvmpipe` render on the CPU (slow, but no GPU needed). | | **dotnet** | Builds all six Institution assemblies (net472 via Krafs reference assemblies — no game install needed to *compile*, only to *run*). | Every one of those — a licensed game binary, non-redistributable Workshop content, a Steam login, a GL-hungry Unity process — is a reason this cannot live in ordinary CI. It is a **local, on-demand integration test**: you run it before a release, on a box that owns the game, and read the verdict. The last line of a good run is `ALL COMPAT ASSERTIONS PASSED`. --- ## Part of the Institution suite The harness in this repo builds and tests the whole suite — **Institution: Core**, **Institution: Contraband**, **Institution: Justice**, **Institution: Gangs**, **Foul Play**, and **Institution: Ward** — in dependency order, alongside the most-subscribed psych/prison Workshop mods, in one running game. Ward carries it because Ward's design *is* a compatibility claim, and a claim like that is worth nothing until it's run.