Developer / fork documentation¶
English only, by design — see the project README for why. This section is for maintaining this mod or forking it, not for playing it — see the player guide for that.
Architecture¶
NTP has no hand-picked ingredient list. At load time it reads every
ItemPrefab/AfflictionPrefab the game currently has loaded — vanilla,
Neurotrauma, and any other active mod — and works out on its own which items
have a real self-use medical effect and which <Fabricate> recipes turn
into which combos. Nothing here is authored per-item; the mod discovers its
own ingredient pool fresh every session, which is also why new content from
other mods needs no NTP update to be picked up (see the
FAQ).
Two files carry that discovery work — pilldata_fallback.lua (the scanner)
and pills.lua (the resolver) — and everything else exists to feed them or
to act on what they produce.
| File | Role |
|---|---|
Lua/Autorun/init.lua |
Load order entry point. |
Lua/Scripts/safecall.lua |
NTP.SafeCall — shared pcall fail-isolation wrapper. |
Lua/Scripts/compat.lua |
Neurotrauma compatibility layer: uses Neurotrauma's real HF/NT/NTC API when present, polyfills a minimal HF plus NTP's own private dispatch tables when it isn't. |
Lua/Scripts/Data/pilldata_core.lua |
Hand-tuned data only: coating tiers (quality/duration bonuses), cosmetic dyes. No hand-picked ingredient pool. |
Lua/Scripts/Data/pilldata_fallback.lua |
The scanner. Reads real <Affliction>/<ReduceAffliction> self-use effects and real <Fabricate> recipes straight off ItemPrefab/AfflictionPrefab at load time. |
Lua/Scripts/pills.lua |
The resolver. NTP.ResolveRecipeCombos (combo/chain/substitution resolution), NTP.PillConfigFromItems (per-ingredient effect assembly), NTP.ComputeBatchScaling (batch yield curves). |
Lua/Scripts/items.lua |
Craft/eat hooks (NTP.TryCraftPills), dose delivery (NTP.StartGradualDose), the PAU and auto-injector hooks. |
Lua/Scripts/chemmastergui.lua |
Client-side custom GUI polish for the ChemMaster (unified backdrop, slot labels, hint icons, drag). |
Lua/Scripts/testing.lua |
Dev-only debug harness, loaded last, non-critical if it fails. Not shipped in a meaningful sense — it's a no-op unless NTP.TestingEnabled is flipped by hand. |
Xml/items.xml |
Item definitions: the ChemMaster itself, coatings, shell, bottles, auto-injector, PAU. |
Xml/Afflictions.xml |
Any mod-added afflictions (most real affliction data is read from Neurotrauma/vanilla, not defined here). |
CSharp/Shared/NTPPillName.cs |
Harmony patch deriving a pill's displayed name/description from its tags on every read — see Pill tag encoding below. |
Localization/*/*.xml |
Player-facing strings. Only English and Russian are current — see Known state below. |
Load order & fail isolation¶
Lua/Autorun/init.lua loads everything via dofile, in a fixed order, each
call wrapped in NTP.SafeCall (except the very first):
safecall.lua— loaded bare, unguarded. It only definesNTP.SafeCallitself, which can't throw at load time, and every subsequentdofileneeds it to already exist in order to guard itself.compat.lua— must load before anything that expectsHF/NTto exist. If it fails, the console gets an explicitCRITICAL: compat.lua failed to loadline and crafting/eating/PAU are disabled for the session, but the game itself keeps running.chemmastergui.lua— client realm only (if CLIENT then), including multiplayer clients, which never run step 4 below. Guarded internally as well as by the outerSafeCall.- Server-side / singleplayer only (
if (Game.IsMultiplayer and SERVER) or not Game.IsMultiplayer then): items.luapills.luatesting.lua— dev-only; a failure here only logs a plain message (no "CRITICAL" prefix, no player-facing impact) since nothing else depends on it.
Every dofile after the first is wrapped independently so that one file
throwing at load time only disables itself — before this was introduced,
an uncaught error aborted the whole chunk executing the dofile chain,
silently skipping every file listed after the one that failed.
NTP.SafeCall(context, fn, ...) (safecall.lua) is the same primitive used
for that guarding and, more importantly, at runtime around every
third-party-facing hook body (crafting, eating, PAU, GUI redraw) — a single
crashing hook call reports context to the console and returns instead of
taking the whole mod, or the whole client frame, down with it. It generalizes
three ad-hoc pcall patterns that predate it; chemmastergui.lua's
per-frame draw calls are the reference example of the pattern in practice.
Neurotrauma compatibility layer¶
compat.lua targets three possible states of the world: original Lua
Neurotrauma installed, the "Neurotrauma CS [Beta]" C# fork installed, or no
Neurotrauma at all. The two real Neurotrauma variants expose an identical
HF/NT/NTC Lua surface (verified call-by-call), so NTP calls that API
directly in both cases and only has real polyfill work to do in the third
case — no Neurotrauma present at all, where compat.lua builds a minimal
HF table standing in for the calls NTP actually uses.
A real ordering bug shaped the rest of this layer: both Neurotrauma variants
do an unconditional NT = {} in their own init (Lua/Autorun/init.lua for
the original, Lua/Shared/SharedExample.lua for the CS fork), with no
NT = NT or {} guard. LuaCs mod load order isn't something NTP controls, so
if NTP happened to load before whichever Neurotrauma variant was active, that
variant's own init would wipe out any table NTP had already registered
custompill/pilljar handlers into — eating a custom pill would silently do
nothing, with no error anywhere. Fix: items.lua no longer touches the
shared NT.ItemMethods/NT.ItemStartsWithMethods at all. It registers into
its own private NTP.ItemMethods/NTP.ItemStartsWithMethods (built
unconditionally in compat.lua), which nothing else can reassign out from
under it, and compat.lua's own dispatch hook reads that same private table.
The scanner (pilldata_fallback.lua)¶
No hand-picked ingredient pool exists beyond pilldata_core.lua's 3 base
substances and 5 cosmetic dyes. Every active ingredient and every
combo/substitution chain is discovered here, at load time, by reading real
game data off ItemPrefab.Prefabs/AfflictionPrefab.Prefabs.
Eligibility gate — NTPF_IsEligible(prefab). An item qualifies as a
candidate active ingredient if:
- it carries a
smallitem/mediumitemsize tag, and - it does not carry an excluded tag, and
- any of:
categorycontainsmedical(substring match — vanilla's own beer bottle iscategory="Medical,Misc", so exact-equality would miss it), or it carries an explicit include tag, oruseinhealthinterface="true", or it has at least one real<StatusEffect statuseffecttags="medical" .../>block anywhere in its config, even nested (this last signal exists because some items — e.g. Arakneas' Cigarette, TSM's Marlboro Cigarette — carry a genuine self-use medical effect undercategory="Misc"with no chem/medical/food tag at all;statuseffecttags="medical"is the game's own per-effect convention for "this is a medical effect" and is more precise than guessing from the whole item).
A real bug lived in step 1 for a while: prefab.HasTag(...) is an item
instance method, not an ItemPrefab one, so the very first check
NTPF_IsEligible made threw on essentially every prefab — 0 ingredients
ever registered, including the vanilla beer bottle, which has no
core/curated entry and depends entirely on this scan. Fixed by reusing the
same tag-set enumeration (for tag in prefab.Tags do) that the
exclude/include checks already relied on.
Recipe/combo discovery — NTP.RunRecipeComboDiscovery(). Scans real
<Fabricate> recipes and enforces a 12-total-item cap, matching the
ChemMaster's 12 active slots: a recipe whose full ingredient count (after
merging duplicate RequiredItem entries) exceeds 12 is skipped rather than
partially represented. This isn't an arbitrary number picked to keep the
data small — of the recipes scanned, only a small minority (7 of 252 in the
reference scan this was verified against) genuinely need more than 12 total
items; the cap simply reflects a hard UI limit that a discovered combo could
never actually be crafted through anyway.
Strength-based substitution — NTP.BuildStrengthSubstitutes(). Builds
Layer B pairing (see tag encoding / the
resolver below for how Layer A vs Layer B priority
works) by comparing real strength profiles between candidate ingredients —
not recipe adjacency. Two documented real bugs were fixed here:
- Self-precursor guard. A naive strength-comparison pass could pair an ingredient with something it itself is a precursor of — that ingredient substituting for its own downstream product does not mean the same thing as two independent ingredients sharing a strength profile. Excluded explicitly via the same precursor data the recipe scan produces.
- Duration double-counting. An earlier version of the strength-profile
comparison summed an effect's duration contribution across every
qualifying
StatusEffectblock on a prefab, when a prefab can carry more than one block granting the same affliction (e.g. two separate stacked analgesia grants) — inflating that ingredient's apparent strength. Confirmed via a real case where this caused an over-strong pairing; fixed to consider the duration only once per identifier.
Name index — NTP.BuildNameIndex(). Builds the identifier↔display-name
lookup the "Target product" ChemMaster field resolves against (see
FAQ) — matching works in
whatever language the player typed, not just the server's configured locale.
NTP.PromoteFabricatePrecursors(). Promotes certain scanned
"hidden-effect" ingredients — ones that would otherwise only ever apply a
generic placeholder debuff — into a real invocation when a genuine handler
exists for them at eat time; see the hook/ tag prefix under
Pill tag encoding below for how that promotion is
carried through to the crafted pill.
The resolver (pills.lua)¶
Pill tag encoding¶
A crafted pill's full effect composition — every ingredient, every effect
strength, the combo(s) that fired, name and description overrides, hidden
"hook" effects — is round-tripped through the item's own Barotrauma tag
list via NTP.PillConfigToTags(config) / NTP.TagsToPillconfig(tags). This
is what lets a pill's real behaviour survive a save/reload with no separate
save-data table: it's encoded entirely as tags on the item itself. Recognized
prefixes:
| Prefix | Holds |
|---|---|
yld/ |
Batch yield (pill count). |
cap/ |
Capacity. |
des/ |
Full decorated description (debug-augmented if NTP.DebugDescriptions). |
nm/ |
Raw user-typed name, before debug decoration — the single source of truth for the pill's real displayed name and per-name stacking, read directly by the NTPPillName.cs Harmony patch. Uses the tag's full suffix rather than a /-split argument so slashes in the name survive; commas are stripped at capture time since the outer tag list is comma-joined. |
ds/ |
User-typed description field, kept separate from des/ so it survives relogin independently of the decorated text. |
col/ |
RGB color, three numeric args. |
ing/ |
One ingredient identifier + amount. |
fx/ |
One resolved effect identifier + total strength. |
fxdur/ |
One resolved effect's duration. |
cmb/ |
One combo that fired on this specific crafted pill: id, reaction count, success flag. The combo's own source data isn't persisted here — resolved at display time by checking whether NTP.PillData.combos/recipeCombos still has that id. |
hook/ |
A promoted hidden-effect ingredient (see NTP.PromoteFabricatePrecursors above) that resolved to a real invocation instead of the generic placeholder debuff: identifier, chance, and mode (call = direct NTP.ItemMethods[identifier] invocation; consume = spawn a real copy and call its own Item.ApplyTreatment, the useinhealthinterface tier-2 fallback). Invoked for real at eat time in items.lua. |
custompill's own prefab tags (Xml/items.xml's Tags= attribute —
smallitem, chem, medical, pill) are named explicitly
(NTP_PrefabTags) rather than skipped by list position, so a prefab-tag
count change (or another mod appending its own tag to the item) can't
silently misalign the cut and swallow or leak tags with no error — that was
a real bug in an earlier position-based version.
NTP.ResolveRecipeCombos(components, skill, success, targetProductId)¶
The core chain-resolution function (~1200 lines). Given the 12 physical items actually in the ChemMaster's active slots, it:
- resolves multi-stage recipe chains via topological sort (Kahn's algorithm), so a combo that itself consumes the product of another discovered combo resolves in the right order;
- applies a surplus/deficit rule when a recipe's ingredients are present in
more than the exact required ratio —
NTP_ComboSurplusCoeff(excess)converts leftover amount into a potency boost fraction (not a multiplier) via a square-root ramp from 0 atexcess=0up toNTP.ComboSurplusBoostCeiling(0.3) atexcess>=NTP.ComboSurplusTo(3 whole doses of leftover). It's added to, not multiplied onto, the skill-potency multiplier —NTP.ComboSurplusMaxCombinedMult(1.5) is a hard safety ceiling on the two combined, not the normally-binding constraint. The square-root shape (front-loading the boost so small excess amounts pay off faster) replaced an earlier straight-line ramp, itself a simplification of an even earlier hyperbolic curve with a separate floor/decay-rate — each revision kept exactly one tunable constant; - dilutes a diverted physical ingredient's dose, limited to identifiers the diverted recipe and the claiming recipe actually share an effect on — an ingredient can't be double-spent past what its own dose supports;
- prioritizes Layer A (recipe-based, from
NTP.RunRecipeComboDiscovery) substitution over Layer B (strength-based, fromNTP.BuildStrengthSubstitutes) when both could apply to the same slot; - honors an optional
targetProductIdoverride — the "Target product" field, already resolved to a real identifier by the caller (items.lua, viaNTP.BuildNameIndex) before this function ever sees it.
A now-removed manual exclusion mini-language (!/!c/!p/!*/+ in a
ChemMaster combo-commands field) used to let players mask out specific
slots from resolution by hand; it was deliberately removed in favor of
always resolving every physical slot automatically, with no manual override.
NTP.PillConfigFromItems(components, skill, descriptionOverride, user, targetProductRaw)¶
Per-ingredient effect assembly: runs each active ingredient's own success/
fail skill roll, then calls NTP.ResolveRecipeCombos to fold combos and
substitutions in, and returns a config table shaped as
{fx, capacity, yield, tags, ingredients, color, description, comboLog,
hookEffects, debugTargetProduct, ...} — this is the table that
NTP.PillConfigToTags later serializes onto the crafted item. A failed
skill roll on an active ingredient still applies that ingredient's own
failure effect if the game defines one, rather than wasting it for
nothing; a combo recipe fires as long as its ingredients are physically
present regardless of skill — skill only scales the result's strength, never
whether the combo fires at all (see
FAQ).
NTP.ComputeBatchScaling(config)¶
Turns a resolved config's total yield into per-pill numbers, capped at
NTP.BatchMaxYield (25):
- Potency bonus — a two-segment curve: a sub-linear ramp
(
NTP.PotencySeg1Exponent= 1.5) from yield 2–15 adding up to 2.5 percentage points, then a steeper linear segment from 16–25 adding up to 10 more. - Light debuff chance — 0 below yield 7, rising on a curved ramp (exponent 1.7) to a peak of 15% at yield 19, one tail point of 5% at yield 20, then 0 again above that.
- Heavy debuff chance — a quadratic ramp starting at yield 15.
- Debuff strength multipliers — separate curves from the chance curves; light strength peaks at 75% (yield 19)/tails to 25% (yield 20), heavy strength is a quadratic solved through three real playtest reference points (15→0%, 20→14%, 25→35%). These are the only place batch size scales a debuff's severity — a debuff's magnitude is never separately divided by yield the way normal effect strength is.
- Ingredient-count bonus — the total number of distinct active
ingredients used (independent of batch yield) adds its own flat bonus to
both light and heavy debuff chance past certain thresholds
(
NTP.IngredientCountLightBonusSteps/HeavyBonusSteps), which is why a batch made from many different ingredients gets debuffs a same-size batch from few ingredients wouldn't (see FAQ).
Normal per-pill effect strength (perPillFx) is the total resolved
strength divided by yield and then boosted by the potency fraction — debuff
severity intentionally does not go through this same division, only
through its own yield-driven curve above.
NTP.AfflictionMaxStrength holds real maxstrength values pulled from the
actual affliction prefab XML (vanilla Content/Afflictions.xml and
Neurotrauma's own override), not invented — used so a reported debuff
strength percentage is relative to the affliction's real cap.
Runtime hooks (items.lua)¶
NTP.TryCraftPills(chemmaster, user, dontreporterrors)— the ChemMaster's craft button entry point; builds the component list from the machine's slots, callsNTP.PillConfigFromItems, and spawns the result.NTP.StartGradualDose(targetCharacter, usingCharacter, fx, fxDuration)plus the"think"hookNTP.PillDoseScheduler— delivers an eaten pill's effects over its real duration rather than all at once.- PAU —
Hook.Add("NTP.PAU.analyze", ...)reports the held stack's real effects to chat, non-destructively, splitting mixed-composition stacks (same display name, different real effects — e.g. after manual renaming) into separate groups with a warning;Hook.Add("NTP.PAU.update", ...)reads the two text fields on the PAU device and respawns the held pills from them viaNTP.SetPillFromConfig, also non-destructively. Neither button consumes pills. - Auto-injector —
NTP.PillJarInject(item, usingCharacter, targetCharacter), wired throughHook.Add("NTP.PillJar.inject", ...). Strength was already fixed at craft time, so no skill check happens at the moment of injection.
NTPPillName.cs (C# Harmony patch, not Lua) replaced an older
pillstacking.lua (removed 2026-08-03) that held a 128-slot runtime-cloned
prefab pool and a round-start pass rebuilding each pill's description. The
Harmony patch has no such cap, needs no synthetic prefabs, and derives the
description on every read instead of once per round — so there's no window
after loading a save where a pill shows placeholder text.
Client GUI (chemmastergui.lua)¶
Four purely-visual, client-side additions on top of the ChemMaster's
existing ItemContainer/CustomInterface XML — none of them touch slots,
fields, the craft button, or networking:
- Unified window backdrop — one bordered frame drawn behind all three component panels (special-slot column, active-ingredient grid, fields+button), recomputed every frame to their combined bounding box.
- Slot labels on the special column (base/blanks/dye/output) — text the
native
<SlotIcon>element can't render (image only), added as children of the special container's own frame so they follow it automatically. - Hint icons on all 16 slots, drawn directly via
Inventory.visualSlots[i].Rectrather than<SlotIcon>. This works around a confirmed real engine bug:Inventory.cs's per-slot icon lookup at draw time callsparentItem.GetComponent<ItemContainer>(), which always returns the firstItemContaineron the item regardless of which container's inventory is actually being drawn. The ChemMaster has twoItemContainers, so any<SlotIcon>declared on either one bled into the other's empty slots.Xml/items.xmlnow declares<SlotIcon>on neither container; this file draws every hint icon itself, sidesteppingGetComponent<ItemContainer>()entirely. - Draggable window — the merged backdrop plus all three panels can be grabbed and moved as one unit, without fighting the backdrop's own per-frame position recompute.
An earlier version tried achieving (1) by reparenting the three panels'
GuiFrames under a shared host frame; this shifted the panels up-right,
because reparenting an engine GuiFrame means fighting RectTransform's
anchor/pivot/scale coupling by hand — setting Anchor = TopLeft does not
move Pivot, which stays at the frame's original anchor="Center". The
per-frame backdrop-behind-everything approach avoids that class of bug
entirely.
Testing methodology & debug tools¶
Lua/Scripts/testing.lua is dev-only, loaded last, and its failure is
non-critical (see Load order above) — it
exists purely as an in-session debug harness, not shipped functionality.
NTP.TestingEnabled—falseby default. Flip totrueto enable the harness for a session.NTP.DebugPillConfig(components, skill)— callable straight from the Lua console; runs the realNTP.PillConfigFromItems/NTP.ResolveRecipeCombospath against a hand-built component list and skill value, without needing an actual in-game ChemMaster craft, so resolver behaviour can be checked against a specific hypothetical ingredient set directly.NTP.TraceRecipeId(pills.lua) — a table of recipe/product identifiers (e.g.viralmedium) to trace; when a traced id is involved,NTP.TraceRecipe(id, msgFn)printsmsgFn's output to the console at each relevant resolution step, letting one specific chain be followed throughNTP.ResolveRecipeComboswithout wading through every other combo's logging.NTP.DebugDescriptions(pills.lua,trueby default in dev) — when set,des/tags get the full decorated debug description (per-ingredient breakdown, combo summary, hook-effect summary) instead of just the player-facing text; seeNTP.FormatDebugDescription,NTP.FormatComboSummary,NTP.FormatHookEffectsSummary.
The general approach throughout is: extract and call the real production
functions (NTP.PillConfigFromItems, NTP.ResolveRecipeCombos, etc.)
against synthetic inputs, rather than maintaining a separate mock
implementation that could drift from what actually ships.
Known state¶
- The ChemMaster is referred to as "Apothecary" in player-facing text and
docs; the underlying prefab/identifier naming still says
chemmasterthroughout the Lua and XML — this is a display-only rename, not a planned code rename. - Only
EnglishandRussianlocalization files are current. The mod's other 7 shipped languages are temporarily behind; missing strings fall back to English at the engine level (no crash, no broken functionality — see the FAQ). - The resolver's surplus-boost curve and the strength-substitution
self-precursor/duration-double-count fixes (both described above) were
each found via real server logs or real in-game craft cases, not
hypothesized — see the inline comments at
NTP_ComboSurplusCoeff/NTP.BuildStrengthSubstitutesinpills.luaandpilldata_fallback.luafor the full history if re-tuning either one. - The manual combo-exclusion mini-language mentioned under
NTP.ResolveRecipeCombosabove is gone; the ChemMaster field it used to occupy is free for a future purpose, not yet decided.