Aller au contenu

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):

  1. safecall.lua — loaded bare, unguarded. It only defines NTP.SafeCall itself, which can't throw at load time, and every subsequent dofile needs it to already exist in order to guard itself.
  2. compat.lua — must load before anything that expects HF/NT to exist. If it fails, the console gets an explicit CRITICAL: compat.lua failed to load line and crafting/eating/PAU are disabled for the session, but the game itself keeps running.
  3. chemmastergui.lua — client realm only (if CLIENT then), including multiplayer clients, which never run step 4 below. Guarded internally as well as by the outer SafeCall.
  4. Server-side / singleplayer only (if (Game.IsMultiplayer and SERVER) or not Game.IsMultiplayer then):
  5. items.lua
  6. pills.lua
  7. testing.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:

  1. it carries a smallitem/mediumitem size tag, and
  2. it does not carry an excluded tag, and
  3. any of: category contains medical (substring match — vanilla's own beer bottle is category="Medical,Misc", so exact-equality would miss it), or it carries an explicit include tag, or useinhealthinterface="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 under category="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 StatusEffect block 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 at excess=0 up to NTP.ComboSurplusBoostCeiling (0.3) at excess>=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, from NTP.BuildStrengthSubstitutes) when both could apply to the same slot;
  • honors an optional targetProductId override — the "Target product" field, already resolved to a real identifier by the caller (items.lua, via NTP.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, calls NTP.PillConfigFromItems, and spawns the result.
  • NTP.StartGradualDose(targetCharacter, usingCharacter, fx, fxDuration) plus the "think" hook NTP.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 via NTP.SetPillFromConfig, also non-destructively. Neither button consumes pills.
  • Auto-injector — NTP.PillJarInject(item, usingCharacter, targetCharacter), wired through Hook.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:

  1. 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.
  2. 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.
  3. Hint icons on all 16 slots, drawn directly via Inventory.visualSlots[i].Rect rather than <SlotIcon>. This works around a confirmed real engine bug: Inventory.cs's per-slot icon lookup at draw time calls parentItem.GetComponent<ItemContainer>(), which always returns the first ItemContainer on the item regardless of which container's inventory is actually being drawn. The ChemMaster has two ItemContainers, so any <SlotIcon> declared on either one bled into the other's empty slots. Xml/items.xml now declares <SlotIcon> on neither container; this file draws every hint icon itself, sidestepping GetComponent<ItemContainer>() entirely.
  4. 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 — false by default. Flip to true to enable the harness for a session.
  • NTP.DebugPillConfig(components, skill) — callable straight from the Lua console; runs the real NTP.PillConfigFromItems/ NTP.ResolveRecipeCombos path 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) prints msgFn's output to the console at each relevant resolution step, letting one specific chain be followed through NTP.ResolveRecipeCombos without wading through every other combo's logging.
  • NTP.DebugDescriptions (pills.lua, true by 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; see NTP.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 chemmaster throughout the Lua and XML — this is a display-only rename, not a planned code rename.
  • Only English and Russian localization 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.BuildStrengthSubstitutes in pills.lua and pilldata_fallback.lua for the full history if re-tuning either one.
  • The manual combo-exclusion mini-language mentioned under NTP.ResolveRecipeCombos above is gone; the ChemMaster field it used to occupy is free for a future purpose, not yet decided.