Per-feature reference: what each one changes, how it was measured, and what every config
key does. README.md has the short version; this is the long one.
Every feature has an on/off switch under [Features] in BepInEx/config/VicMod.cfg, and
its own section below it for tuning. A feature that fails to apply is logged and skipped,
and the game still boots.
Multiplies the staff slots the research tree grants. Defaults: 3 slots at the start,
+3 per researched capacity upgrade (so 3 → 6 → 9 …). Vanilla is InitialStaffCapacity
(0 or 1) and +1 per upgrade.
How the vanilla system works, from the disassembly:
StaffManager.CurrentMaxStaffCapacityis aNetworkVariable<int>.StaffManager.Initializeseeds it from the difficulty'sInitialStaffCapacity, walksGameSettings.Research.StaffCapacityUpgrades, and callsExpandStaffCapacity()once per upgrade already researched. For upgrades not yet researched it subscribes a listener that callsExpandStaffCapacity()when they unlock.ExpandStaffCapacity()is exactlyValue = Value + 1.
Capacity therefore only changes at two events, so VicMod hooks those and nothing else — no
Update, no UI patch, no polling:
| Hook | What it does |
|---|---|
ExpandStaffCapacity postfix |
tops the game's +1 up to SlotsPerUpgrade |
Initialize postfix |
authoritative BaseCapacity + researched * SlotsPerUpgrade |
Existing saves work. Initialize replays the researched upgrades on every park load, so
the recompute lands on load with no save editing.
Config: BepInEx/config/VicMod.cfg, section [StaffCapacity] — BaseCapacity
(default 3) and SlotsPerUpgrade (default 3). The [Features] section has the on/off toggle.
Multiplayer: CurrentMaxStaffCapacity is a NetworkVariable, so only the host can change it.
On a client the first write is rejected, and the feature logs once and goes inert rather
than throwing on every event.
Not touched: StaffSettings.StaffCountLimitByJob, a separate per-job cap checked only by
TabletUI when hiring. Say the word if you want a knob for that too.
The game pays less research XP when a job is done by an employee than when you do it yourself. This restores parity: by default a staff-completed action awards exactly what the same action awards you.
How the vanilla system works, from the disassembly of ResearchSystem.OnActionCompleted:
- Finishing anything raises an
ActionCompletedEventcarrying anEEventSource—LocalPlayer(1),RemotePlayer(2),Staff(4),Visitor(8). Forty-one classes raise it;StaffCleaningInteraction,StaffRepairingInteractionandStaffPushBadVisitorInteractionpassStaff. OnActionCompletedturns the action name into XP withResearchSettings.GetReward(name), scales that by the difficulty's research-reward multiplier, plays the local XP sound if the source wasLocalPlayer, and then — behind anIsServercheck — multiplies again byResearchSettings.staffReserachMultiplierif and only if the source is exactlyStaff, before callingAddExperience. The game's own tooltip on that field: "What % of research reward you receive when Staff is doing your job."
The entire penalty is that one float, and the misspelling is the game's, not a typo here.
The test is == Staff rather than a flag test, so Visitor-sourced actions are already paid
in full.
There is nothing to Harmony-patch. The compiler inlined the field load into the single
mulss that applies it, and the public StaffReserachMultiplier getter is not called
anywhere in the binary — so patching the getter changes nothing, and by the time
AddExperience runs the source is gone. The only lever is the field itself, which VicMod
writes on the live GameSettings.Research object.
| Hook | What it does |
|---|---|
ResearchSystem.Initialize postfix |
writes the field at park load, so the log reports it early |
ResearchSystem.OnActionCompleted prefix |
writes it if it is not already right |
The prefix re-reads the field instead of latching a "done" flag, because ResearchSettings
is a ScriptableObject VicMod does not own and nothing promises it is the same instance across
two park loads — a flag would leave a reloaded object at the vanilla penalty with the log
claiming otherwise. Re-reading costs one cached-reflection read per completed action, single
digits per second at worst. The write is confirmed by reading it back, because it goes
through an unmanaged field pointer where a silent no-op is a real failure mode; a mismatch
disables the feature for the session rather than lying about it.
Config: section [StaffExperience], key Multiplier (default 1). A scale rather than a
switch, because "the same as the player" and "no penalty" are only the same answer if you
accept that the shipped number is the wrong one — 0.5 puts a gentler penalty back, 0
stops employees earning anything, 2 pays a premium for delegating.
Multiplayer: AddExperience sits behind IsServer (NetworkBehaviour+0x3A), so only the
host awards XP at all. Writing the field on a client is inert rather than harmful.
The vanilla value is not known yet. The ResearchSettings asset is not in
resources.assets, globalgamemanagers.assets or any level* file, nor in
defaultlocalgroup_assets_all.bundle; the other 538 addressable bundles were not searched,
because the running game answers the question for free. The feature logs the value the first
time it replaces it —
StaffExperience: staff XP share X -> 1. Send that line and it belongs in this paragraph.
Removes swimwear from guest NPCs, and optionally undresses staff. On by default.
A garment leaves four separate marks on the body, which is the whole difficulty here.
setApparel:
- destroys the old piece and spawns the new mesh under
ApparelParent; - when
slot == 0, writes_Neck_Shrink(fromApparel.NeckShrink) to materialsM_CC_HeadandM_CC_Body— this shrinks body geometry so it can't poke through the garment; - writes
table.SkinMaskPropertywithApparel.Maskto three materials —M_CC_Head,M_CC_BodyandFirstPersonBody— masking the skin away underneath; - when
Apparel.FootOffset.HeightOffset >= 0, callssetBodyCustomizationthree times with the garment'sFootRotation,BallRotationandHeightOffset— so a garment can reshape the body throughModifyBone, not just mask it. (Found by resolvingsetApparel's call targets against the[Address(RVA=…)]map; it is easy to miss.)
Hide the mesh and leave the shrink and the masks behind and you get a pinched, half-erased
body. So VicMod asks the game to undo it: most apparel tables carry a "none" entry (an
Apparel with no Mesh), and re-running setApparel with that index unwinds mesh, shrink
and masks through the game's own code path.
setTextureProperty cannot clear a texture, which is a trap worth knowing about. Given
a null texture it falls back to Resources.Load(p.stringValue) and returns early when
that misses — leaving the previous garment's mask on the body. So a "none" entry that
carries no blank mask of its own un-equips the mesh but not the mask. That is what kept
slot-1 male trunks looking squished after the first fix. Wherever that applies (and on the
manual path for a table with no "none" entry at all), VicMod writes
Material.SetTexture(maskProperty, …) directly instead.
| Hook | What it does |
|---|---|
CharacterCustomiserUtility.Start + RandomiseAll postfix |
registers CharacterCustomization → owning utility, so the guest's AIBrain is reachable later |
CharacterCustomization.setApparel postfix |
un-equips, if the wearer qualifies and the piece (guest) or the slot (staff) is in scope |
setApparel is synchronous and fires once per apparel piece as a guest is dressed, so there
is still no polling. A re-entrancy guard stops the un-equip call from recursing.
Matching is by item name, not category. MenuCategory turns out to identify the
slot, not the garment type — so Upper_Body holds both Bikini_03_Black and Lab Coat,
and the Swimwear table holds Overalls and Dress 03 next to the one-piece
Swimsuit_01_* items. Categories alone strip the wrong things in both directions.
Every apparel item observed in build 24862398, by slot:
| Slot | MenuCategory | Swimwear items | Also in this slot |
|---|---|---|---|
| 0 | Upper_Body |
Bikini_02_Black, Bikini_03_Black, Bikini_03_Flower |
Lab Coat, Hoodie, Winter Jacket, Sport Bra, Crop_Top_01, … |
| 1 | Lower_Body |
Bikini_01_F, Bikini_02_*, Bikini_03_*, Swimming_Trunks_01, Swimwear_01_a/b/c |
Jeans, Skirts, Cargo_Pants_01, Yoga Pants, … |
| 1 | Swimwear |
Swimsuit_01_BlueGlass, Swimsuit_01_Sport, Swimsuit_01_Striped |
Overalls, Dress 03, Dress 04 (full-body, not swimwear) |
| 2–4 | Footwear, Headwear, Hats |
— | shoes, glasses, caps |
The default NamePatterns of Bikini, Swim, Crop_Top matches every swimwear item and nothing
a strippable guest wears otherwise.
Crop_Top is not a guess. Three guests were seen wearing a top and nothing else — bottoms
stripped, top left on. Read live off scrObj_Outfits_Standard, the game's own Swimsuits table
lists the crop tops in the same slot-0 option list as every bikini top:
Swimsuits [Bra] slot0 = Crop_Top_01, Crop_Top_02, Bikini_02_Black/Blue/Red,
Bikini_03_Black/Flower/USA
slot1 = Bikini_01_F, Bikini_02_*, Bikini_03_*
Swimsuits [Swimsuit] slot1 = Swimsuit_01_Striped, Swimsuit_01_Sport, Swimsuit_01_BlueGlass
Outfits_Adult_F slot0 = Shirt_01, Blazer, Cardigan, Lab Coat
So the game will issue bikini bottoms with a crop top, and Bikini, Swim caught only half the
outfit.
Know what else it does. Crop_Top_01 also appears in Outfits_Teen_F[1].slot0 — the teen
casual outfit — and IsStrippableGuest has no age gate, so a teen in street clothes loses it
too. That is the same trade the Sport Bra pattern already makes (same table, same outfit), and
it is a real consequence rather than a gated one. Drop Crop_Top from the list if you don't want
it. The player's own wardrobe carries both crop tops and is excluded — by having no AIBrain,
nothing to do with age.
The structurally correct test is table membership — is this garment in the swimsuit outfits
table for this character? — rather than a name substring. That is now reachable
(CharacterCustomiserUtility.customizationSettings → FemaleSwimsuitOutfitsTable →
Outfits[].OutfitOptions[].Options) and is the right fix if a third gap turns up. It was not
built for one gap.
Instead, the gap now announces itself. After stripping one body slot, if the other body slot still holds a garment no pattern matched, one warning is logged per garment name:
GuestSwimwear: stripped "Journee" (Female/Adult) slot 1, but slot 0 still holds
"Crop_Top_01" [Upper_Body], which no NamePatterns entry matches.
The condition is a real signal rather than a heuristic: a two-piece loses both halves, a one-piece occupies slot 1 alone, and a man in trunks has an empty slot 0. A leftover on the paired slot is either swimwear no pattern covers or street clothes worn over swimwear — it cannot tell those apart, so it asks rather than asserts. The point is that the next gap arrives as a log line instead of as a screenshot.
Config ([GuestSwimwear]): NamePatterns (default Bikini, Swim, Crop_Top — case-insensitive
substrings of the item Name, the primary rule), MaskFallback (default None; Black/White
if the shader's mask convention turns out to be inverted), StripStaff (default Off;
Female, Male or All), KeepStaffSlots (default Headwear, Hats).
StripStaff defaults to Off because there is no defensible guess between Female, Male and
All — it is the one setting here whose right value is a preference rather than a measurement.
Guests are unaffected by it either way; it only ever adds employees.
Turning it on costs you the one thing that made an employee recognisable at a glance — the uniform. StaffMarker is the answer to that, and puts either the job on their nameplate or the job's hat back on their head.
Level = Debug under [Logging] writes one line per removal, so grepping a guest's name gives
everything taken off them and what each piece had stamped on the body:
GuestSwimwear: "Ada Nowak" (Female/Adult) slot 1 removed "Swimwear_01_a" [Swimwear]
mask=T_CC_Mask_Swimsuit_01 neckShrink=0.35 foot=(h=-1 fr=0 br=0)
_Neck_Shrink M_CC_Head=0.35 M_CC_Body=0.35 -> M_CC_Head=0 M_CC_Body=0 via none #0 + mask clear
The same level then dumps the full body state of each stripped guest once — every
slot's current garment, all four mask slots (_UpperBody_Mask, _Neck_Mask,
_LowerBody_Mask, _Footwear_Mask) plus _Neck_Shrink on each body material, every
ModifyBone value, and every non-zero blendshape.
That split is the point. Two very different faults look identical in-game:
- a mask slot still naming a garment texture, or a non-zero
_Neck_Shrink, means the removal left something behind — fixUnequip; - all of those clean, but the leg bones (
ThighScale,CalfScale,LegsWidth,HipWidth) sitting near zero while the torso ones (ShoulderWidth,UpperTorsoSize,UpperWaistSize) are high, means nothing was left behind at all — the game never shapes legs it expects to keep covered, and the fix is to drivesetBodyCustomizationourselves.
The second is what "macho upper body, twiggy legs on buff male guests" sounds like, but the dump is what decides it.
WhatToRemove returns which of two rules applies:
| Wearer | Rule |
|---|---|
A player avatar (a PlayerCharacter that points back at this customiser) |
every slot KeepPlayerSlots does not hold back, if StripPlayer selects them — Off by default |
Anything else with no AIBrain |
nothing, always |
Mascot (AIDataStorage.MainJobType) |
nothing, always, including under StripStaff = All |
Any other employee (AIBrain.IsStaff) |
every slot KeepStaffSlots does not hold back, if StripStaff names their sex |
| Everyone else | garments matching NamePatterns |
Staff are stripped by slot, not by pattern, and that is forced rather than chosen. Six
employees read live in a running park wore Sleeveless_Staff_01, Hoodie_Staff_M,
Jeans_Baggy_Black, Boots_01 and Sneaker_02 — not one contains Bikini, Swim or
Crop_Top, so matching on names here would have been a setting that did nothing.
KeepStaffSlots therefore names what to keep, defaulting to Headwear, Hats; everything
else goes. Naming what to keep is also the safer polarity, because slot layouts are not uniform:
three of those six carried [Upper_Body, Lower_Body, Footwear, Headwear, Hats] and a fourth had
Outfit where the others had Headwear.
StripStaff reads the sex from AIDataStorage.Sex, the field the game itself hands to
RandomiseAll, falling back to CharacterCustomiserUtility.Sex; the two agreed on all six
employees. Neither can say "not known yet" — the backing field defaults to 0, which is
Male — so a dress that lands before the field is filled reads Male. All does not consult it
at all.
StripPlayer is Off, Me or Everyone, and it defaults to Off because
WardrobeNone exists — that feature puts the game's own "None" button back in
the editor's body slots, so an undressed avatar can be a saved choice rather than a rewrite of
every dress. The two cancel: with StripPlayer = Me you are stripped whatever the editor was
told, which makes those buttons pointless for your own character. KeepPlayerSlots is empty by
default, so a selected avatar loses every slot.
Identification is positive, not the absence of a brain. "No AIBrain" is also true of the
character creator's mannequin and of any other AI-less character, so this asks for a
PlayerCharacter — on the parent chain first, then a scan down from the transform root — and
accepts it only when its own CharacterCustomiserUtility is the one being asked about. That
identity check is the one Jiggle.FindUtility makes and exists for the same reason: a component
search that walks past the character it started in returns somebody else's answer, and here that
would undress whatever the search landed on.
Read live through the Probe in a loaded park: the local customiser sits at
Player(Clone) Local [#0] → PlayerCharacter Local [#0] → Systems →
CharacterCustomiserUtility, so GetComponentInParent reaches it two hops up and the
PlayerCharacter.CharacterCustomiserUtility field points back at it. A single-player park holds
two PlayerCharacters — the second is an inactive template reading IsOwner = false — which
is why Me gates on IsOwner and is the value to use when playing alone. Everyone would take
the template too; it is inactive, so nothing renders, but that is what the value means.
Unlike StripStaff this needs no dressing-again path. An employee is saved, so stripping one
records naked in HiredStaff.VisualData; a player avatar is loaded from
CC_SaveData.SavedCharacters — written by the character creator — and
CharacterCustomiserUtility has no save-out method at all, so turning the setting off and
reloading restores the outfit with no repair step.
Client-side visual only: apparel objects and material properties are local, so this changes nothing for other players and cannot desync.
Adds the missing "None" buttons to the character editor's body slots, so undressing your own avatar is a wardrobe choice saved with the character rather than something forced at dress time.
The entry already exists and the game hides it. Item 0 of every apparel table is an
Apparel named None with a null Mesh and the display name </> — the same entry
GuestSwimwear re-applies to undress somebody, and the one the game itself applies to
Upper_Body whenever a one-piece from the Swimwear table is worn. What keeps it out of the
editor is a single bool, hideInCustomizationUi, read in Apparel_Menu.createApparelButtons:
the disassembly copies the struct to the stack and does cmp byte ptr [rax+40h],0 before it will
make a button, ahead of the UnlockRequirement check at +0x48.
Read live off ten tables in a running park — exactly three of the twenty flags are set:
| Upper_Body | Lower_Body | Footwear | Headwear | Hats | |
|---|---|---|---|---|---|
| Female | hidden | hidden | visible | visible | visible |
| Male | visible | hidden | visible | visible | visible |
So seven "None" buttons are already in the editor and always have been: you can take your hat
and shoes off today, and a man can already go shirtless. That asymmetry is the evidence the
entry is meant to work — the game ships the button for one sex's chest and not the other's.
Slots defaults to Upper_Body, Lower_Body, which covers all three; naming a slot that is
already visible does nothing.
Apparel is a non-blittable il2cpp struct — it holds strings and references — living in a
List<Apparel>. Reading one back through the indexer hands over a detached box. Setting the
flag on that box was measured through the Probe to report before: true, after: false while the
list itself still read true: the exact reports-success-changes-nothing shape this project keeps
getting caught by. Writing the box back into the list is the operation that corrupted
AINetManager.DailyHires and crashed the game (see VisitorSexRatio), and
List<T>.Add is broken the same way, so the list cannot be rebuilt either. One byte at a
computed address is what is left.
The layout is proved before the write, not checked after — the standing rule for a write that
can corrupt memory. It is affordable here for a reason peculiar to this table: every field the
write depends on can be read independently through the interop indexer. So the array header size
and the element stride are searched, not assumed, and a candidate pair is accepted only if it
reproduces both the MenuCategory int at +0x3C and the hideInCustomizationUi byte at
+0x40 for every entry in the table, against what the indexer says they are. A stride wrong
by one machine word walks the enum read into the middle of another element's UnlockRequirement
pointer, which will not spell a valid MenuCategory forty-three times running; requiring the
bool as well stops a table whose flags happen to be all-zero from carrying a wrong stride through
on the enum alone. If no pair agrees, nothing is written and the log says so. The post-write
read-back goes through the indexer, so it reads the array rather than a box.
The game's files are not touched. UpperBody_F and the rest are assets in
resources.assets, but only the loaded copy is edited, so quitting the game undoes it and a
Steam file verification or a game update is irrelevant.
Marks employees so you can still tell them from guests once GuestSwimwear's StripStaff has
taken their uniform away. Mode ships Off — it is a fix for a problem another setting
creates, and it does nothing until you pick one of the three answers.
| Key | Default | What it does |
|---|---|---|
Mode |
Off |
Off, Nameplate, Hat or Sunglasses. |
NameplateFormat |
{name} ({job}) |
The plate text. {name} and {job} are substituted; TextMeshPro rich text works. |
JobNames |
see below | Job=Label pairs. Keys are the game's EStaffJobType names; labels are yours. |
HatSlots |
Hats, Headwear |
Which MenuCategory slots count as the hat slot, in preference order. |
HatNames |
Cap, Cap_Security, Officer Hat, BucketHat, Beanie |
Which hat, tried in order. Exact match preferred, substring as fallback. |
GlassesSlots |
Headwear, Glasses |
The same, for Sunglasses. |
GlassesNames |
Sunglasses, Aviator_Glasses, Glasses Agent, Glasses |
Which eyewear, matched the same way. |
OnlyWhenStripped |
true |
Whether Hat/Sunglasses only mark employees who are actually undressed. |
Which to pick. Nameplate is the only one that says which job, and the only one that
cannot be mistaken for a guest's own clothing. Hat is the most visible of the two garment modes
and costs the character their hair, because the hat mesh replaces it. Sunglasses leaves the
hair alone and is correspondingly harder to spot across a park. The two garment modes are the
same code against a different slot and a different name list.
Nothing is patched at all while Mode is Off, so the inert case costs one line in the log.
StaffManager.StaffByJobType is a Dictionary<EStaffJobType, List<AIBrain>>. It is private,
but the two public accessors over it are — read from the disassembly — literally [job].Count
and [job][i] with a bounds check and a ContainsKey guard:
GetStaffCountByJob(job) -> StaffByJobType.ContainsKey(job) ? StaffByJobType[job].Count : 0
GetStaffByTypeAndIndex(job, i) -> i < that count ? StaffByJobType[job][i] : null
So walking EStaffJobType's own values (minus the internal MAX) enumerates every hired
employee with no il2cpp dictionary marshalling and no per-character hierarchy search. That is
why this runs on a timer — every 30 frames, about half a second — rather than hooking
setApparel or anything per-frame.
Every mode re-applies rather than applies once, deliberately. A nameplate is pooled and reused, and an employee is re-dressed on every park load and every re-randomise, so a mark written once is a mark that quietly vanishes later — the same silent-regression shape as GuestSwimwear's employees who came back saved-naked. Each pass compares what is there against what should be there and writes only on a difference, so a steady park costs one string compare per employee per half-second.
AIBrain.SpawnedNameplate is a TrackedFloatingText — the game's own floating label — whose
text is a public TMP_Text. The disassembly shows that text written in exactly one place,
AIBrain.OnNameChanged, which is subscribed to the _AIName network variable by the
AwaitNameplate coroutine and called once directly when the plate spawns. So the rewrite has one
thing to race with, and that one thing is patched: a postfix on OnNameChanged re-applies the
label immediately, and the timer covers everything else.
It does not touch the name. AIDataStorage._AIName is a NetworkVariable<FixedString64Bytes>
that is saved and synced; the write here goes to the TMP component on a pooled UI object. Nothing
reaches a save file and nothing reaches another player.
JobNames exists because two of the game's own job names are actively misleading rather than
merely ugly: Healing is the lifeguard and SellingTickets is the manned ticket booth. The
default list renames those two and Maintenance, and passes the rest through. A job you delete
from the list falls back to the game's own name for it.
If the plate is null the mode is silent, because null is ordinary — the plate does not exist until
AwaitNameplate has found the FloatingTextSystem, and it goes back to the pool when the
character despawns.
Puts a hat or a pair of sunglasses on, through CharacterCustomization.setApparel. Going through the game's method
rather than spawning a mesh is the same argument GuestSwimwear makes for taking
a garment off through the table's None entry: a garment leaves three marks on the body — the
mesh, the _Neck_Shrink on M_CC_Head and M_CC_Body, and the skin mask on those two plus
FirstPersonBody — and the game applies all three. Reproducing them by hand is what produced the
pinched, half-erased bodies the first time round.
One garment for every job, because the uniforms do not carry hats. This was read out of the
outfit tables in resources.assets rather than guessed. Every staff outfit's hat slot:
| Outfit | Hat slot | Glasses slot |
|---|---|---|
Staff_F / Staff_M |
(nothing) | (nothing) |
EMT_F / EMT_M |
(nothing) | (nothing) |
Staff_Lifeguard_F / _M |
None |
None |
Staff_Mascot_F / _M |
Mascot_Head |
None |
Staff_Security_F / _M |
Cap_Security |
Aviator_Glasses_Security |
So security is the only job in the game with a hat, and there was no per-job hat to find for
anybody else. An earlier draft of this feature read the job's uniform through
StaffGenerationSettings.GetOutfitOverrideForJob and offered its garments to the hat table; that
was deleted once the table above was read, because for eight jobs in nine it would have found
nothing.
Headwear is the glasses slot. The same read settles a second thing. An outfit's
OutfitOptions are in apparel-slot order, and a character carries
[Upper_Body, Lower_Body, Footwear, Headwear, Hats] — so the fourth option is the Headwear
slot and the fifth is Hats. The fourth holds Glasses, Sunglasses, Aviator_Glasses,
Glasses Agent; the fifth holds BucketHat, Beanie, Cap, Construction Helmet,
Officer Hat. That is why HatSlots defaults to Hats, Headwear and not the other way round:
matching Headwear first would put a hat name into the glasses slot and find nothing.
HatNames therefore ships a real ordered list rather than a lookup, and GlassesNames does the
same for the other slot. Each entry is matched exactly first and by substring second, which
is what lets the list say Cap and mean the baseball cap rather than Cap_Security, and
Sunglasses rather than Sunglasses_CaseOhs_Mom. Substring remains the fallback so a list
written against a slightly different spelling still lands on something.
Cap leads the hat list because it is the plain baseball cap. Cap_Security is next and is
worth knowing about: it is the only hat in the game that no guest outfit ever issues, so it
is the only one that reads as a uniform rather than as a fashion choice. Move it first if a plain
cap is too easy to mistake for a guest's. Construction Helmet also exists and is the least
subtle option there is. Aviator_Glasses_Security is the same argument for the eyewear slot.
Two guards on when it fires:
- The slot must be empty. A hat already on their head is never replaced, whether the game put it there or you did.
OnlyWhenStripped(defaulttrue) additionally requires both body slots to be bare — the sameIsUndressedtestGuestSwimwearuses before dressing an employee again. Set itfalseto hat every bare-headed employee instead, which is a fuller-uniform look rather than a marker.
The resolved name is cached per apparel table, keyed by the table's own pointer — the tables are
shared ScriptableObjects and there is one per sex, so it settles after two entries. The index
is still looked up per character through scrObj_Apparel.GetApparelIndexFromName, because the
slot a table sits in is not fixed: three employees read
[Upper_Body, Lower_Body, Footwear, Headwear, Hats] and a fourth had Outfit where the others
had Headwear.
If nothing in HatNames matches, one warning names every hat that character's table actually
holds — so the name to put in HatNames arrives in the log rather than being guessed at.
The write is verified by re-reading the slot through the indexer afterwards, not by
setApparel returning — it returns void, and "the write reported success and changed nothing"
is the single most common failure mode in this codebase.
A cap is a weaker marker than a nameplate, and worth knowing before you pick it: guests wear
Cap, BucketHat, Beanie, Sunglasses and Aviator_Glasses too. What distinguishes an
employee is the combination — undressed and wearing one — which is exactly the case
OnlyWhenStripped produces. Cap_Security and Aviator_Glasses_Security are the two entries
that do not have this problem, because no guest outfit lists either.
GuestSwimwear postfixes setApparel and strips whatever lands in a slot KeepStaffSlots does
not name. With headwear not on that keep-list — which is the configuration that leaves the slot
empty in the first place — it would strip the hat back off inside this feature's own
setApparel call. So GuestSwimwear.Suppressed is held across the write, exactly as its own
_unequipping flag is held across an unequip. Order of the two Harmony postfixes does not matter,
because the flag is checked at the top of the postfix rather than relied on to run second.
No mode has been watched in a running park. Specifically:
- Whether AI nameplates are rendered at all in normal play. The machinery is unambiguous —
AwaitNameplate,SpawnedNameplate,FloatingTextSystem.SpawnTrackedText— but the coroutine gates on something onGameManagerbefore it spawns anything, and what that gate is has not been read. If plates never appear,Nameplateis silent and does nothing. - Whether
Cap_Securityis in theHatsapparel table and not some security-only one. It is referenced byStaff_Security_FandStaff_Security_Magainst that slot, so it has to be, but that is inference from the outfit tables and not a read of the apparel table itself. If it is not there the next name inHatNamesis used and the log says which. - Whether
TMP_Text.textreached through the interop proxy's virtual setter writes the visible label. It is a property with a setter on the generated proxy; that is not the same as having seen it change on screen.
Scales down the wet-skin effect, which at full strength reads as shiny plastic, and retunes the skin's dry specular alongside it — the wetness float turned out not to be where most of the problem lives. On by default, like everything else.
The wet part of it rides on one shader float. WetnessSystem holds
Shader.PropertyToID("_Wetness") and ApplyWetnessToMaterials does nothing but
m.SetFloat(WetnessPropertyID, currentWetness) over its cached materials — whatever the
skin shader does with roughness and metallic is driven off that single value, so scaling it
is the whole tweak.
There are exactly two write paths, both events:
| Hook | What it does |
|---|---|
SetTargetWetness postfix |
rewrites the replicated targetWetness the lerp heads toward |
ForceSetWetness_Rpc postfix |
rewrites currentWetness directly, then re-applies |
ManagedUpdate does the lerping and only touches materials when the value moves, so this
adds no per-frame cost. PlayerWetnessSystem.Update just calls WetnessSystem.ManagedUpdate,
so guests and your own first-person hands are both covered by the same patch.
Config ([Wetness]): Scale (default 0.4 — multiplier, 1.0 is vanilla), Maximum
(default 1.0 — hard cap applied after Scale, so Scale 1.0 + Maximum 0.5 keeps light
drizzle linear but clamps the peak), LogShaderProperties (default true — one-shot dump of
every skin-material property and its current value, before anything is written to it).
_Wetness only scales how wet a character is, and the glossy look survives at zero wetness —
so it is not the wet look that is wrong, it is the dry one. There are two separate causes and
they need different knobs.
The stylised one. The skin shader carries its own highlight, _USE_FAKE_SPECULAR driving
two lobes (_FS_Strength_1/_2 at _FS_Gloss_1/_2), which sits on top of the normal
lighting and does not vary with the surface beneath it. On pale skin that mostly reads as
sheen. On dark skin the same near-white lobe against a much darker albedo is an enormous local
contrast, and high contrast plus a tight highlight is precisely the visual signature of moulded
plastic — which is why dark characters read as bakelite rather than as people.
Three things are done about it, in decreasing order of how principled they are. The highlight
is tinted toward the character's own _SkinColor, so it belongs to the surface it sits on. It
is broadened, because a scattering surface does not hold a tight lobe. And it is weakened, more
so on darker skin — that last one is a perceptual fudge rather than physics, since real skin
does not get less shiny as it darkens, and it can be set to zero.
The log says this lobe ships at _FS_Strength_1 = 0.1, which is small. If characters read as
polished metal rather than as plastic, this is not what is doing it.
The physical one. Shader_CC_Skin exposes _WorkflowMode and _SpecularTint and no
_Metallic at all: that is URP's specular workflow, in which _SpecularTint is not a
decoration on a highlight but the surface's reflectance at normal incidence — its F0. Skin
reflects about 4% of light head-on and scatters the rest, so its F0 is nearly black. A tint
anywhere near white is a declaration that the character is made of polished metal, and a
renderer handed that will faithfully mirror the sky and the surroundings into them.
_Skin_Smoothness then decides whether what comes back is a sharp image of the pool or a soft
sheen.
So there are two knobs on that, and neither is a fudge — both move the material toward what skin measurably is:
SpecularTintScalescales the tint's brightness afterTintToSkinhas coloured it. This is the metal knob. Lower it until characters stop reflecting their surroundings.SmoothnessMaxis an absolute ceiling on_Skin_Smoothness, applied after theSmoothnessmultiplier. A multiplier cannot promise a matte surface — 0.75× of 0.95 is still a mirror — and a ceiling can. Real skin sits somewhere around 0.3 to 0.5.
| setting | default | effect |
|---|---|---|
SkinSpecular.Strength |
0.45 |
multiplier on both fake-specular lobes |
SkinSpecular.Gloss |
0.7 |
multiplier on lobe tightness; lower spreads the highlight |
SkinSpecular.Smoothness |
0.75 |
multiplier on _Skin_Smoothness, which feeds real lighting |
SkinSpecular.SmoothnessMax |
0.45 |
absolute ceiling on it afterwards; 1 disables the ceiling |
SkinSpecular.TintToSkin |
0.6 |
blend _SpecularTint toward _SkinColor |
SkinSpecular.SpecularTintScale |
0.5 |
scale its brightness afterwards — the metal knob |
SkinSpecular.FakeSpecular |
Keep |
Off removes the stylised lobes outright |
SkinSpecular.DarkCompensation |
0.5 |
extra reduction proportional to skin brightness |
FakeSpecular writes the shader keyword as well as the float of the same name. A Shader
Graph boolean marked as a keyword compiles to both, and setting only the float — which is what
shows up in the property list — does nothing at all, because the branch was compiled out.
Values are recomputed from each material's originals rather than multiplied in place, so a
material passing through CacheMaterials twice does not end up matte. Those originals are
keyed by shader-and-material name, not by the material's pointer: the instanced materials are
per-character copies of two shared source materials, so one entry covers every instance. The
pointer-keyed version had a cap and a Clear() past it, which threw away the originals for
materials that were still alive and still retuned — so the next pass captured already-reduced
values as if they were the game's and multiplied them again, and around seventy characters into
a session skin would start creeping toward matte.
Every setting above is a multiplier on a number, and none of those numbers were visible.
LogShaderProperties (default on) now dumps each skin material's properties with the value
each one is carrying, once per session and before anything is written to them. That line is
what says whether Smoothness = 0.75 is a real change or a rounding error, and what
_SpecularTint actually ships as.
./scripts/log.sh -a | grep 'Wetness: material' # or grep the file directly:
grep 'Wetness: material' "$GAME_DIR/BepInEx/LogOutput.log"Secondary motion on breast_l, breast_r and optionally butt_l, butt_r: the bones lag
behind the body and settle, rather than being welded to the ribcage.
Nothing about the mesh had to change for this, and it works on the stock bodies as well as the replacements. The rig already carries these as dedicated bones, weighted the way a jiggle bone is weighted rather than incidentally:
| bone | LOD0 | LOD1 | peak influence |
|---|---|---|---|
breast_l / breast_r |
205 vertices each | 110 / 111 | 0.97 |
butt_l / butt_r |
232 / 228 | 162 / 159 | 0.77 |
A peak influence of 0.97 means the breast tip is effectively owned by that bone, so moving it carries the whole breast and falls off cleanly into the chest. The glutes are weaker, which is why they are a separate switch that defaults to off — at the same amplitude they visibly drag the hip along.
Where it runs is the whole trick. CC.ModifyBone drives these same bones through Unity
PositionConstraints in its OnLateUpdate; that is how BreastSize and ButtSize offset
them, and CayplayMissions.CustomizationNew.BonesUpdater.LateUpdate is what pumps it once per
frame per character. Hooking ModifyBone directly is the obvious choice and the wrong one:
BonesUpdater skips bones whose update flag is clear, so the hook would silently never fire
for a character whose customisation is static. Hooking BonesUpdater runs unconditionally,
and runs after every constraint has written, so the displacement lands on top of the game's
own offsets instead of fighting them for the transform.
The awkward part is knowing what the rest position is. The bone is read back each frame so
that BreastSize keeps working — but if nothing else wrote it that frame, what is sitting
there is our own displaced value from last time, and treating that as the rest position lets
the offset compound until the breast leaves the body. Comparing the transform against exactly
what we wrote tells the two cases apart.
The first version simulated a point in world space that chased the bone's anchor. That is the
obvious construction and it has a defect that only shows up once the character starts moving: a
spring chasing a moving anchor settles at a constant lag of v · damping / stiffness. At a
2 m/s walk with stiffness 90 and damping 14 that is 31 cm, against a ceiling of 3.5 cm — so for
the entire duration of any walk the bone sat pinned at full travel.
Pinned did two things at once, and they were the two visible symptoms:
- the offset sat at maximum pointing backwards, and since the bone is a translation, backward travel does not swing the breast, it flattens it into the ribcage — and forward travel stretches it off the chest;
- the clamp bled the velocity by the clamp ratio every frame it was over, which during a walk
was every frame. At 3.5 cm against a demanded 31 cm that is roughly 89% of the velocity
destroyed per frame. The result was not merely limited, it was continuously drained, which is
why it read as sluggish no matter what
StiffnessandDampingwere set to.
Driving the offset from the anchor's acceleration instead is both the fix and the more
honest model: a body moving at a constant speed has no secondary motion, and a body that bobs,
turns, stops or lands does. Each axis integrates o'' = -k·o - d·o' - a, where a is the
matching component of the anchor's world acceleration — the pseudo-force a passenger feels,
which is why the bone lags downward when the body is thrown upward. Constant velocity now
produces exactly zero displacement, and the bounce comes from the step impacts and torso sway,
where it actually comes from.
The acceleration is read off a low-pass on the anchor's velocity rather than by differencing
position twice: acc = alpha · (raw − smoothed) / span is a first-order high-pass with the same
average, and it does not turn one noisy frame of animation into a kick. MaxAcceleration
catches a teleport.
Driving from acceleration is only as good as the acceleration, and the first version measured it badly enough that the spring was being driven by the frame rate rather than by the body.
The first real log — a thousand MotionReports lines off a running park — said so without
ambiguity:
| median | p75 | p90 | max | |
|---|---|---|---|---|
| peak drive, up (m/s²) | 31 | 124 | 184 | 200.0 |
| peak travel, up (cm) | 2.23 | 3.50 | 4.55 | 4.55 |
The bold numbers are not measurements, they are limits. 200.0 is MaxAcceleration exactly;
3.50 cm and 4.55 cm are the vertical ceiling for a male and for a female at 1.3× travel. Every
axis told the same story, and the arithmetic settles what caused it: a walk bob is about
2 Hz, and 200 m/s² at 2 Hz would mean the chest was travelling a metre up and down. Whatever
was being differentiated, it was not the body.
Three things were wrong with the estimator, and all three are fixed in Step:
- One low-pass followed by a difference is not a smoother. Above its corner the gain stops
falling and sits flat at
1/VelocitySmoothing, which at 0.02 s is fifty. Every frequency the anchor happened to carry — the animation clock, the render clock, the beat between them — arrived multiplied by fifty. The time constant is now applied a second time, to the acceleration differenced out of the velocity, which turns that plateau into a roll-off. It costs a 2 Hz walk bob a few per cent and an artefact at the frame rate an order of magnitude. - A discontinuity is not an acceleration. The estimator, once the three faults below were
fixed, still saw raw samples reaching 29,000 m/s² — a metre of travel between two frames,
which is a guest being moved across the park or a pooled body handed to somebody else, not a
body accelerating.
MaxAccelerationwas the wrong instrument for it: trimming a teleport to 40 m/s² still drives the spring at full strength for as long as the second pole takes to ring down.TeleportAccelerationdiscards the sample instead and re-seeds, costing two frames of drive. The two rails answer different questions — trim what is merely hard, discard what is impossible — and nothing physical lives between 40 and 400 m/s². - A frame where the anchor did not move is not a measurement of zero. The animation, the agent and the render loop do not have to tick together, and when they do not the anchor's world position is a staircase: flat for a frame or two, then a step covering all of it. Differencing that per frame reports alternating zero and multiples of the true speed. The held time is now carried and the eventual step divided by the span it really covers.
- The measurement clock was clamped.
dtwasmin(deltaTime, 1/30)everywhere, including in the divisor of a difference — where clamping does not make anything safer, it inflates the result: a 50 ms frame divided by 33 ms reports a velocity half again too big, and then an acceleration built out of two of those. Measurement now uses the true frame time; only the integration is bounded, and it substeps rather than clamping, so a slow machine gets a slower simulation instead of a different one.
Simulating the estimator against a smooth 1.4 m/s walk with a 1.5 cm, 2 Hz bob — a true peak vertical acceleration of 2.4 m/s² — shows the size of it:
| render / animation clock | old estimator | new estimator |
|---|---|---|
| 60 / 60 Hz | 2.3 | 2.1 |
| 60 / 30 Hz | 9.6 | 1.8 |
| 120 / 30 Hz | 27.5 | 2.2 |
| 144 / 20 Hz | 52.8 | 1.9 |
The old column is the mismatch between two clocks, reported as though a body had done it. The new column is the body.
There is a second reason the rail was the operating point, and it is independent of the
measurement — it is arithmetic on the config values alone. A sustained acceleration a
displaces the bone by a / Stiffness. At Stiffness = 120 that is 8.2 cm per g, so a
3.5 cm ceiling is reached at 0.43 g — which a walk clears without trying, and a large chest
reaches its own 4.55 cm ceiling at 0.45 g because StiffnessAtLargest softens the spring in
step with raising the ceiling.
So the pair had never been consistent: the ceiling was not a safety limit above the motion, it was inside it, and a bone that lives on a clamp is not being simulated. Clamping is the one step in here with no physics in it.
Stiffness = 200 sags 4.9 cm per g and reaches the ceiling at 0.71 g, which puts a walk at a
fraction of full travel and leaves the clamp for the teleports it was written for. Damping
follows it to 14, about half of critical, where the second swing is a quarter of the first —
one clear bounce rather than a wobble. The startup line now prints both derived numbers, so
the relationship is visible without doing the division:
Jiggle: breasts, stiffness 200 (2.25 Hz), damping 14 (0.5 of critical), axis gains up 1 /
side 0.7 / forward 0.3, 2x stiffer rising, ceiling 3.5 cm at 1x -- a sustained 1 g displaces
4.9 cm, so the ceiling is reached at 0.71 g
A constant force on a spring only moves where it rests, and where the bone rests is what the
rig ships — so adding g to the drive would displace every breast downward by a fixed amount
and change nothing whatsoever about the motion. Free fall is handled too, and correctly: an
anchor accelerating downward at g produces an upward pseudo-force, which is a breast floating
up off the chest, which is what happens.
What is genuinely missing when up and down look alike is that tissue is not one spring. A
breast rises against the chest wall and against its own compression; it falls on skin and
ligament that stretch much more freely. UpwardStiffness is that asymmetry — a multiplier on
k applied only while the offset is above rest, and only on the vertical axis, where alone it
means something. At the default of 2 the bone travels about 0.8× as far up as down and the
upward half runs about 40% faster. Push it much past 3 and the two halves stop reading as one
motion.
A breast swings up and down freely, sideways a fair amount, and in and out of the chest hardly at all — there is a ribcage in the way, and the in-out axis is the one that stretches and flattens rather than swinging. So each axis gets its own gain and its own ceiling, and clamping per axis rather than on the total length is the other half of the washout fix: one shared budget meant a large forward component ate the vertical bounce along with it.
Which axis is which was measured off the rig rather than guessed. Composing the bind chain in
work/mesh/CC_Body_Female_LOD0.json gives, for spine_05_wt (the breasts' parent) and
pelvis_wt (the glutes'), local axes of:
| parent-local axis | world direction in rest pose | role |
|---|---|---|
| X | (0, 0.98, −0.18) |
up |
| Y | (−1, 0, 0) |
side |
| Z | (0, 0.18, 0.98) |
forward, out of the chest |
It holds for all four bones and on both LOD0 and LOD1, and it is a property of the skeleton
rather than of the pose, so it is hardcoded. A game update could re-author the rig, and then
the gains would silently land on the wrong axes — so Jiggle: <bone>'s parent … axes in world
is logged once, and on an upright character X should read near (0 1 0) and Z near (0 0 1).
| setting | default | what it does |
|---|---|---|
Breasts |
true |
drive breast_l and breast_r |
Glutes |
false |
drive butt_l and butt_r |
Stiffness |
300 |
sets the bounce frequency, sqrt(k)/2π ≈ 2.76 Hz, and the sag per g, 9.8/k ≈ 3.3 cm |
Damping |
20 |
critical is 2·sqrt(Stiffness) = 34.6, so this is a little under 0.6 of critical |
VerticalScale |
1.0 |
gain and travel on the up axis — the bounce |
SideScale |
0.7 |
gain and travel on the lateral axis — the sway |
ForwardScale |
0.3 |
gain and travel in and out of the chest — the flatten/stretch axis |
UpwardStiffness |
2.0 |
extra k while the bone is above rest — tissue resists rising, hangs falling |
VelocitySmoothing |
0.045 |
seconds; applied twice, to the velocity and to the acceleration read off it |
MaxAcceleration |
40 |
m/s², four g — trims a drive harder than a body produces |
TeleportAcceleration |
400 |
m/s²; above this the sample is discarded and the spring re-baselines |
MaxOffset |
0.035 |
metres, per axis and scaled by that axis's gain |
Strength |
1.0 |
scales the result; 0 disables without unhooking |
StiffnessAtSmallest |
1.1 |
stiffness multiplier on the smallest chest |
StiffnessAtLargest |
0.55 |
stiffness multiplier on the largest chest |
AmplitudeAtSmallest |
1 |
travel multiplier on the smallest chest |
AmplitudeAtLargest |
1.7 |
travel multiplier on the largest chest |
RestSagAtSmallest |
0.003 |
metres the smallest chest settles below the rig's rest pose |
RestSagAtLargest |
0.012 |
metres the largest does — the difference is what reads as weight |
MotionReports |
12 |
measured-motion reports per character, then that character goes quiet |
MotionReportCharacters |
40 |
how many characters ever get a reporting slot |
With the default gains the ceiling works out at 3.5 cm up and down, 2.4 cm sideways and 1.1 cm
in and out, and Stiffness = 300 puts that ceiling at 1.07 g of sustained drive — above a walk
rather than inside it. Across the size range the spring runs 2.0–4.1 Hz, which is where
breast tissue actually sits.
Three knobs, three different questions, and it is worth keeping them straight:
Stiffness— how fast. It sets the frequency, and cuts travel as a side effect.Damping— how much it bounces, at a fixed frequency. Raise it to settle sooner.VelocitySmoothing— how lively it looks. The anchor on a real character is noisy enough that a good part of the visible motion is the estimator's residual rather than the body; this is the knob that trades that off. Lower for more life and more jitter.
Damping was previously documented as critically damped at roughly sqrt(Stiffness). That is
wrong by a factor of two — for unit mass it is 2·sqrt(k) — and the old default of 14 was
therefore 0.64 of critical rather than the intended lively value. The default is now 8.
Stiffness scales with chest size. mod_breast_shrink is the only breast blendshape on the
body and its weight runs 0 at the largest to 100 at the smallest, which makes it a
self-normalising size reading — better than ModifyBone.currentValue for
CC_ModifyType.BreastSize, which needs ModifyBoneRanges.breastSizeRange fetched alongside
before the number means anything. It is read fresh each frame, so the response follows the
slider while a character is being edited.
That direction is measured, not assumed. At weight 100 the shape pulls the breast in: the tip
vertex's distance from the midline goes 0.1297 → 0.1083 m and 69% of the 100 vertices breast_l
owns move inward. So 0 is the largest and 100 is the smallest.
CC_Body_Male_LOD0 carries the same 89-bone skeleton as the female — breast_l,
breast_r, butt_l and butt_r included — and 31 blendshapes against her 32. The one it does
not have is mod_breast_shrink, exactly. That is a true fact about the two meshes, and it was
used to decide which characters get breast springs. It does not work, and the log said so:
Jiggle: a body carrying no mod_breast_shrink … so its breast bones are left alone.
with Jiggle: driving N bone(s) per character never printed once in the entire session. No
character on the map was getting a breast spring — women included.
The reason is that a character does not have a body mesh. CharacterRenderers holds every
body it could ever wear, all at once. BodyMesh's own survey line, on one character, reads:
CC_Body_Female_LOD0 *, CC_Body_Female_LOD1 *, CC_Body_Female_LOD2, CC_Body_Female_LOD3,
CC_Body_Male_LOD0, CC_Body_Male_LOD1, CC_Body_Male_LOD2, CC_Body_Male_LOD3,
CC_Teeth_Female_LOD0, CC_Teeth_Male_LOD0, First_Person_Male
All eight bodies are bound to the same skeleton, so all eight carry breast_l. Probing the
shape on whichever renderer happened to yield that bone first was answering a question about
list order, and the order it got back was one without the shape.
So the sex now comes from the game: CharacterCustomiserUtility.Sex, an ECharacterSex
(None = -1, Male = 0, Female = 1). That utility is not the component being patched, so it
is found on the hierarchy: GetComponentInParent first, then a search down from
transform.root.
Whatever that lookup returns is checked for identity before it is believed. Neither search
is guaranteed to stop inside the character we are driving. GetComponentInParent walks up until
it finds a utility; the search from transform.root walks down from wherever the character
happens to be parented, and if guests hang off a shared spawner object then root is that
object and the downward search returns whichever character it reaches first — handing one
character's sex to every character in the park. So a candidate is accepted only when its own
CharacterCustomization field is the same object this BonesUpdater points at. A wrong answer
becomes no answer, and no answer is logged.
Sex is re-read every frame, not decided once. A character is spawned before it is
randomised or loaded from save data, so what Sex says on the frame its BonesUpdater first
ticks is not necessarily what it says a moment later — and the private sex field defaults to
0, which is Male. Deciding once meant an early read was cached for the life of the
character with nothing to correct it. Reading it per frame costs one property get beside the
several transform calls already happening per bone, and it makes the customisation screen behave,
where the sex changes under a character that is already being driven. A group that stops being
driven is stepped at amplitude 0 rather than skipped, which zeroes the spring and writes the
rest position back, so the chest returns to the rig instead of keeping its last offset.
Unknown counts as female. The two ways to be wrong are not symmetric. A chest that moves when it should not is visible and has a switch; a chest that refuses to move disables the feature in silence. That silence is exactly the bug above, which survived a commit, a documented rationale and a round of config edits without being visible to anyone.
mod_breast_shrink is still looked for, and now across all of a character's renderers rather
than one of them — but only to read a size off. It no longer decides whether anything moves.
| setting | default | what it does |
|---|---|---|
MaleChests |
false |
drive the breast bones on characters the game reports as male |
MaleStiffness |
2.5 |
stiffness multiplier for a male chest when they are driven — 3.6 Hz absolute |
2.5 sits deliberately outside the female range rather than at its stiff end: a pec is not a
small breast, it is nearly rigid. It came down from 4 when Stiffness went up from 120, so
that a male chest sits at about the same absolute 3.6 Hz it always did. Note that raising stiffness cuts amplitude and raises
frequency, so a very large value buzzes rather than stills — to remove the motion, turn the
switch off rather than winding the multiplier up.
These two replace BreastsWithoutSizeShape and StiffnessWithoutSizeShape, which were named
after a test that no longer exists. Delete those from the config; BepInEx keeps orphaned keys
forever otherwise.
A character that resolves to no springs is remembered as resolved rather than retried. That distinction did not matter when everything got springs; with men skipped it is roughly half the guests in the park, and each would otherwise be re-searched for 120 frames.
It was used as one for three revisions, on the strength of a plausible story — the only breast blendshape on the body, weight 0 at the largest and 100 at the smallest, self-normalising — and nothing ever printed the number. The first log that did was unambiguous:
600 report windows, 12 different women, one distinct value:
chest 100 of full.
Twelve women reading identically is not a size, and the user was looking at guests of visibly different chests at the time. A reading that cannot come out wrong cannot come out right either. Whatever the weight was answering — which renderer the list happened to yield first, or a shape the game simply does not use to dress a guest — it was not "how big is this chest".
CC_ModifyType.BreastSize is what the game itself drives, and it was sitting in the
GuestSwimwear body report the whole time:
bones (23): … NeckLength=0.5 ButtSize=0.5 BreastSize=1 HipWidth=1.2334512 …
Every character has one, it is per-character, and ModifyBone_Manager.Lerp(Vector2 range, float t) says what currentValue is for.
ButtSize=0.5 and NeckLength=0.5 sitting next to each other looked like 0..1 slider
positions with 0.5 in the middle, so currentValue was used directly whenever it landed in
0..1 and normalised against breastSizeRange only when it did not. That was a guess, and the
next log disproved it in the most direct way available — by printing both halves:
Jiggle: … scaled by CC_ModifyType.BreastSize (range 1 to 2).
…
"Sophia" (female, chest 100 of full [BreastSize=2, mod_breast_shrink=0] …
"Leona" (female, chest 100 of full [BreastSize=1, mod_breast_shrink=0] …
breastSizeRange is (1, 2). 1 fell inside the 0..1 shortcut and returned 1.0; 2
fell outside it and normalised to (2−1)/(2−1) = 1.0 as well. Both ends of the range mapped
to "largest", so 234 windows again printed one answer — this time with the range announced in
the log line directly above them and nobody putting the two side by side. Normalise against the
range, unconditionally; there is no case where currentValue is already a fraction.
(1, 2) also says something about the game rather than about the bug: currentValue is a
scale multiplier, 1 for the mesh as authored and 2 for double. There is no chest
smaller than default in this game. StiffnessAtSmallest and its pair are misnamed —
"smallest" is the default body, which is what almost everyone in the park is, so that end of
each pair should sit near neutral and the AtLargest end should carry the whole difference.
The first log taken with the reading working says how lopsided, across 20 women:
BreastSize |
women | normalised |
|---|---|---|
exactly 1 |
17 | chest 0 of full |
1.075, 1.088 |
2 | chest 8, chest 9 |
exactly 2 |
1 | chest 100 of full |
So the game does randomise it — but 85% of women sit exactly on the authored mesh, the tail is
a rounding error, and the single guest at the top is at the hard maximum. That is why the park
reads as "either default or larger": it very nearly is. Whatever spread the size pairs are
tuned to give, 17 of 20 women will never see it, and that is a property of the generator
rather than of this feature — see the generator levers in CLAUDE.md.
The same log also retires the two members that were printed to settle the direction: alpha=1
and inverted=False on every character, at every size. alpha is the constraint blend
weight, not a slider, and nothing is inverted. currentValue normalised against the range is
the reading.
The blendshape is kept only as a fallback for a rig without the bone. And everything the
conclusion was drawn from is now printed, not just the value: the range it was normalised
against, the bone's own alpha and Inverted flag, and the old blendshape weight.
"Leona" (female, chest 0 of full [BreastSize=1 of 1..2, alpha=0, inverted=False,
mod_breast_shrink=0], 2 bone(s) …
alpha is declared [Range(0f, 1f)] on ModifyBone and Inverted is a bool beside it;
neither has ever been looked at, and either could still flip the sense of the reading, so they
are printed rather than assumed. That is the lesson from the revisions this shape survived: the
story about what a number means is not evidence, and a value used for tuning has to appear in
the log next to every other value that was needed to interpret it.
StiffnessAtSmallest and StiffnessAtLargest scale the spring constant, and damping is scaled
by the square root of the same factor alongside — the damping ratio goes as c / (2·sqrt(k·m)),
so scaling k alone leaves a large chest over-damped and reading as sluggish rather than heavy.
Taking both together moves the frequency and leaves the settling alone, which is what more mass
on the same spring actually does.
It is also nearly invisible. A driven oscillator's travel goes as a / |k − ω² + i·d·ω|. A walk
bob lands somewhere around 1.5–2.5 Hz, and the spring's own frequency across the size range at
the defaults is 1.7–2.8 Hz — so the drive sits at or near resonance, where the denominator is
set by ω and the damping rather than by k. Softening a large chest therefore changes how it
moves without changing how far, and every size travels about the same distance.
AmplitudeAtSmallest and AmplitudeAtLargest are the missing half. They ride on the axis gains,
which scale the drive and that axis's ceiling together, so the proportions of the motion are
untouched and only its size changes. Set both to 1 to have size drive frequency alone.
Everything above is about motion, and motion is not the only thing size should change. The rig
ships one rest pose and uses it at every chest size — nothing in the game makes a large
chest hang lower than a small one — so at rest every size sits exactly where the bone was
authored, which reads as fitted rather than as tissue. RestSagAtSmallest and
RestSagAtLargest are the correction, 3 mm to 12 mm against a 35 mm ceiling.
It is applied as a constant upward acceleration on the anchor, not as a fixed displacement.
That is the equivalence principle rather than a trick — a frame accelerating upward at g is
indistinguishable from standing in gravity — and it is the same sign convention as everything
else here, since o'' = -k·o - d·o' - a settles a positive a to a negative (downward)
offset. Two things follow that a fixed displacement would not give:
- the spring bounces about the sagged position rather than about the rig's, which is what a hanging mass does;
- it lives in world space, so it follows the pose — a character on a lounger sags toward the ground rather than toward their own feet.
The acceleration is derived from k (a = sag·k/gain), so the sag stays the distance you asked
for when Stiffness is retuned underneath it. Verified by simulation: at every size and every
stiffness setting the offset settles at exactly the configured value.
This will not fix a chest that reads as implants. A bone is a translation — it moves the whole breast down as one piece, and cannot make the lower pole fuller or the upper slope concave, which is most of what the eye is actually reading. That part is a sculpt, not a spring.
Glutes are deliberately excluded from all of it: ButtSize moves a bone but drives no
blendshape, so there is no size to read — and therefore no size to hang off either.
MotionReports is what turned the tuning above from guesswork into arithmetic, and it is still
the only thing that will settle whether the current numbers are right. Each line carries, over
the preceding four seconds: peak drive per axis, the raw drive before the smoothing poles
and before the clamp, how much of the window the clamp fired on, how much of it the anchor
never moved on, peak travel per axis against the ceilings in force, the size factors that
produced them, and the frame rate they were measured at.
Those added numbers are what tell a body moving hard apart from a measurement of the frame rate. The second log, read on 2026-08-25 after the estimator was fixed, split cleanly on the held share and settled what each fault was worth:
| windows | median raw drive | |
|---|---|---|
| anchor held < 1% of frames | 664 | 68.6 m/s² |
| anchor held > 50% of frames | 190 | 1.1 m/s² |
The held-anchor guard did its whole job: a character whose animator is culled now produces
almost no drive at all, where before it produced the largest spikes in the log. What it does
not touch is a character updating every frame whose anchor is simply noisy, which is the 68.6
— and that is what VelocitySmoothing is for.
Travel is read the same way it always was: sitting exactly on the ceiling means the rail has
become the operating point and Stiffness is what to raise; sitting far under it means the
amplitude multipliers are the knob that matters.
The budget is per character, and every line carries the name, the sex, the chest size and
where the sex came from, so the numbers can be read against a specific body instead of being
averaged over whoever walked past. The name comes from
CharacterCustomiserUtility.AIBrain.Data.AIName, not from
CharacterCustomization.CharacterName — the latter is the prefab the guest was stamped out of,
and the first thousand reports came back naming everyone Default_Male, including the lines
that correctly identified them as female. The brain is populated after the body, so the lookup
is retried on each report until it answers and cached from then on. MotionReportCharacters caps how many characters ever get a
slot; slots are handed out in the order characters are first driven and never returned, so the
first wave of guests is measured and the rest of the session is silent.
Reporting does not begin until LevelManager.IsGameplayScene says a park is loaded. The first
version had a single global budget, which the one character standing on the title screen spent
in full before the game had finished loading. If that lookup is unavailable the gate opens rather
than closing — a diagnostic silenced by a failed lookup is worse than a talkative one — and says
so once.
The offset is carried in the parent bone's frame, which rotates with the torso — that is what makes the displacement follow a character who turns. It also makes the frame non-inertial, and the Coriolis and centrifugal terms are not modelled; at the rate a walking torso rotates they are well below the ceiling and below what is visible.
Replaces whole character meshes with .vmesh files shipped next to the plugin, matched by
name. Off by default — set BodyMesh = true under [Features]. VicMod ships sculpted
CC_Body_Female_LOD0 and CC_Body_Female_LOD1 that add real nipple geometry under the
painted areola.
Why replace rather than patch the asset. The bodies ship compressed
(m_MeshCompression = 1) and m_IsReadable = false, so at runtime there is nothing to edit —
Mesh.vertices on a non-readable mesh comes back empty. Patching resources.assets in place
is the other option and it works, but it can only move existing vertices, and around the
chest the stock mesh has nothing to move: the nearest neighbours are 11.4 mm apart. Every
displacement-only attempt made the breast pointier instead of adding a nipple. Replacing the
mesh lifts that cap — topology, density and UVs are all editable — and the result lives in
the mod folder, so it survives game updates and uninstalls cleanly.
A .vmesh carries everything the engine needs, because none of it can be borrowed from the
mesh being replaced: positions, normals, tangents, UVs, skin weights, bindposes, per-submesh
index buffers and blendshape deltas. See src/VicMod/VMesh.cs for the byte layout.
Two orderings are load-bearing. The game indexes into both, so anyone re-authoring a file must preserve them:
- submeshes line up with the renderer's
materialsarray, - blendshapes are driven by
BlendshapeManagerby index.
Bones are not: the file names them, and the renderer's bones array is reordered to match at
load time. If a named bone is missing the swap is abandoned rather than half-applied.
Meshes are built once each, flagged HideAndDontSave, and assigned to sharedMesh;
blendshape weights are carried across by name. Building one is a handful of memcpys into
il2cpp arrays rather than tens of thousands of reflected calls — Interop.NewArray allocates
through Il2CppStructArray<T> and copies the file's buffers straight in, after checking the
element size matches what the caller expects.
Stretches the in-game day. Multiplier, default 3.
TimeSystem.DaytimeLength is a plain public float, next to DayStartHour and
DayParkCloseHour. It is scaled rather than replaced, because its units are unknown — real
seconds per in-game day is the obvious reading, but nothing has ever printed the number, and this
project has been burned three times by exactly that kind of assumption. A multiplier is correct
whatever the units are, and the log prints both values so one session settles it:
DayLength: DaytimeLength 180 -> 540 (x3); park runs 08:00 to 20:00.
Patched on both Initialize and SoftInitialize, scaling from a per-instance recorded
baseline — otherwise the two hooks would multiply in sequence and cube the day length. Opening
and closing hours are untouched; the day still starts and ends where it did, it just takes longer.
The game already has a lighter option. time.Slower, time.Faster and time.Pause are
console commands on this same system (see docs/commands.md). If the goal is only to stop the
clock while doing something else, those need no mod and no restart.
Multiplayer: this writes a plain field rather than a NetworkVariable, so unlike
StaffCapacity it will not be rejected on a client — but time is server-driven, so a value set
on one peer and not the other is a desync, not a feature. Both sides need the same number.
Widens the game's own visitor body generator. Vanilla, read live off all 38
VisitorBodyCustomizationOverride components in a loaded park and identical on every one:
bodyRandomizationChance = 0.25
BodyRandomization GlobalFatness (-1.000, 0.924) BreastSizeRange (0, 0.5047) ButtSizeRange (0, 0.5047)
ObeseBodyRandomization GlobalFatness ( 0.811, 0.905) BreastSizeRange (0.4416, 0.6625) ButtSizeRange (0.4227, 0.6814)
Two throttles, stacked. Three quarters of visitors are never body-randomised at all and keep
the mesh as authored; the quarter that are draw a t capped at 0.50, half of what the range
can express. These are t values lerped through ModifyBoneRanges.breastSizeRange, which is
(1, 2) — so vanilla's ceiling is a currentValue of 1.5 and a doubled chest simply never
occurs. That is why 17 of 20 women in a park read as exactly the default, and why Jiggle's
size pairs had almost nothing to act on.
These numbers were measured, not inferred. With BreastSizeRange widened to (0, 1), twelve
ApplyOverride() calls on one guest returned 1, 2, 2, 1.44, 1.46, 1.70, 1.29, 1.11, 1.24, 1.67, 1, 1.09 — a full spread including the 2 vanilla cannot reach, with roughly the share left at
exactly 1 that the chance roll predicts. This is the only feature here whose constants were
read out of a running game before being written, which is what the Probe is for.
| setting | default | vanilla | |
|---|---|---|---|
RandomizationChance |
0.85 |
0.25 |
share of visitors randomised at all |
BreastSizeMin / Max |
0 / 1 |
0 / 0.505 |
position in the game's own range |
ButtSizeMin / Max |
0 / 1 |
0 / 0.505 |
|
FatnessMin / Max |
0 / 0 |
-1 / 0.924 |
inert: Max <= Min leaves the game's pair alone |
Fatness is left alone by default because it drives a dozen bones at once and widening it is a far bigger visual change than the two above. Any pair can be disabled the same way.
Written from a prefix on both OnEnable and ApplyOverride, because which runs first depends on
pool reuse, and writing is idempotent. BodyRandomization is a struct field, so the Vector2s
are written into the boxed copy and the whole box written back — mutating what Interop.Get
returned and stopping there would change nothing, silently. Same write-back the Probe's /set
does.
One thing this does not explain. A guest was observed at currentValue = 2 before any of
this, which neither vanilla table can produce — the obese one only reaches 0.66. Some third
path sets breast size (a preset, a trait, or AIRandomiser) and has not been found. Guests
arriving through it ignore all of the above.
Sets how often a visitor is female, and optionally how often a staff member offered for hire is.
FemaleShare (default 0.5), HireableStaff (default true), StaffFemaleShare (default
-1, meaning follow FemaleShare). Any share below 0 disables that half without turning the
feature off.
There is no weighting field to edit — the dump was searched for every spelling. Vanilla is a
straight coin flip: AINetManager.SpawnVisitor calls Random.Range(0, 2) when its
ECharacterSex characterSex = None parameter says "you decide". So 0.5 is a measured default,
not a guess at one.
Both were tried against that spawn parameter, and both are worth knowing about because each one looked like it was working.
- Rewriting the argument. A prefix wrote
object[] __args, which is the documented way to change an argument without naming its type at compile time. It logged its own rewrite every launch and did nothing: withFemaleShare = 1a park came out 18 female to 27 male. Harmony 2.10.2's__argswrite-back does not reach the il2cpp method. The write into the array is real and observable, so from inside the prefix "changed" and "changed and then discarded" are indistinguishable. Only counting bodies could tell them apart. - Reading the result. A postfix taking the returned
AIBrainfailed at patch time withIL Compile Error (unknown location), which names neither the parameter nor the reason. Four signatures were tried —IntPtr __resultandIl2CppRef __result, each with and withoutobject[] __args— and all four were rejected identically. Il2CppInterop's detour patcher cannot inject__resultfor a method returning an il2cpp reference type. Note the limit of that evidence: every candidate carried__result, so it says nothing about postfixes in general, andIl2CppRef __instanceon a void method is unaffected.
The rule both point at: patch where a value is read, not where it is written.
AIDataStorage.DressCharacter is where the sex is actually consumed — it reads the _Sex
NetworkVariable and hands the value straight to CharacterCustomiserUtility.RandomiseAll four
instructions later. A prefix there needs no argument rewrite and no return value, and it makes
the spawn path irrelevant: a fresh visitor and one revived from the dormant pool both arrive
here. Every write is verified by reading the field back.
The name is chosen earlier, so it has to be redone. AIBrain.SetDormant picks from
GameSettings.Common.StaticData.MaleNames/FemaleNames long before DressCharacter runs, which
is why the first working version produced female guests called Liam. The name is reissued only
when the sex actually moved.
Staff are skipped here — AIDataStorage.IsStaff is a plain bool while AIBrain.IsStaff is a
NetworkVariable<bool>, and copying the .Value read across returned null so the guard never
fired for anyone and every employee in the park was re-sexed and renamed. Both shapes are
accepted now, and an unreadable flag counts as staff: skipping a visitor loses one roll, while
renaming an employee cannot be undone.
An employee's sex is decided days before they are dressed, in the tablet, by
StaffGenerationSettingsSO.GenerateStaff. That returns a Staff struct, so neither lever
above applies — and unlike the visitor case, there is nowhere to write afterwards either.
Two attempts, both instructive, the second destructive.
- Rewriting the hire card.
StaffCardUI.ConnectedStaffInfois a real copy of the offer and is what the hire flow reads, so it looks like the right target. It is written byRebuildHireableStaffEntries, which runs after this postfix — andGenerateDailyHireableStaffopens withReleaseAllHireCards(), so the rewrite landed on cards already back in the pool and was then rebuilt from the untouched list. It returned silently, which made a feature that never ran indistinguishable from one with nothing to do. - Rewriting the offer list.
AINetManager.DailyHiresis the real store.Interop.SetAtwrote the struct shifted by 0x10, the size of the il2cpp box header:Name(0x8) came back as the box's monitor field, so null;Level(0x3C) as a float out of the middle ofStats; the four bools at 0x40–0x43 as more float bytes; and the object references at 0x58/0x60 as garbage. The hire list rendered a row reading103400839 $1123858936/dayand the game crashed. The read-back fired and refused to continue — after the entry was already wrecked. For a write that can corrupt memory, verifying afterwards is the wrong guard.
What replaced the read-back settles the question. Before touching the game's list, the operation
is proven once per session on a scratch List<Staff> built here and seeded with a copy of a real
offer. On a live park it came back:
VisitorSexRatio: a Staff added to a scratch list did not read back intact.
Add fails the same way the indexer did, so it is not the indexer: a non-blittable il2cpp
struct cannot be passed to a generated method on this build at all. Whether the same applies to
a struct field on a class (Interop.Set(obj, member, box)) is still untested — VisitorVariety
writes BodyRandomization, but that one is blittable and comes back as a real C# struct, a
different path.
The offer is made to come out right rather than corrected. The sex is a bare local:
mov edx,2 / xor ecx,ecx / call Random.Range ; the roll, 0..1
mov [rsp+31h],al
...
shr rax,8 / test al,al
jne -> mov rdi,[rbx+50h] ; UnclaimedFemaleNames
mov rdi,[rbx+48h] ; UnclaimedMaleNames
Answer that one draw and the game picks the matching name itself, asks Twitch for a chatter of
that sex itself, and hands the right outfit override to the hire. No rename, no ClaimName
bookkeeping, nothing to keep in step. 1 is Female, which is ECharacterSex.Female, so the two
agree by construction.
Three patches, all or none:
| Patch | Why |
|---|---|
TabletUI.GenerateDailyHireableStaff prefix/postfix |
a window, so the ambulance's paramedics — same generator, not the tablet — are not caught |
StaffGenerationSettingsSO.GenerateStaff prefix/postfix |
arms one answer per call, so a single call cannot answer two draws |
UnityEngine.Random.Range(int, int) prefix |
answers it — resolved by parameter type, since GenerateStaff calls both overloads and arity alone would be a coin flip |
Presets are excluded for free: the preset branch returns before the roll. The one collision is
that the same branch draws Range(0, ValidPresetCount) first, and a park with exactly two valid
presets would be indistinguishable — it fires at UsePresetChance, live value 0.01, and costs
one unweighted preset offer, which this would have skipped anyway.
Verified in a running park, FemaleShare = 1: two generations, ten offers, ten female, ten
draws steered — one per offer, nothing over-firing — with Level, JobType and the hire cards
all intact, and no duplicate names. The same park had been giving 3 male / 2 female.
Multiplayer clients are not covered. A client receives the day's offers through
AINetManager.SendDailyHires_Rpc(Staff[]) rather than generating them, so no draw of theirs is
ever answered. In a hosted game the host generates, so the gap is client-side only.
Employees already hired are not touched. Their body is built from AIDataStorage exactly as a
visitor's is, but re-rolling one mid-employment renames someone the park already knows.
Reweights the skin tone the generator draws from. Weights, default 1,1,1,1,1,1,1,1,1,1 —
identical to vanilla, so it ships inert.
scrObj_Randomizer_Standard.skinTints is a List<SkinTintSetting> (a Color and a
Saturation), one per randomiser — Randomizer_Male and Randomizer_Female — and both hold the
same ten-rung ladder, read live:
[0] 1.000 0.961 0.949 sat 1 [5] 0.773 0.663 0.498 sat 2
[1] 0.980 0.902 0.902 sat 1 [6] 0.502 0.345 0.318 sat 4
[2] 0.992 0.831 0.780 sat 1 [7] 0.502 0.286 0.165 sat 4
[3] 1.000 0.746 0.665 sat 1 [8] 0.384 0.227 0.090 sat 4
[4] 0.816 0.620 0.490 sat 2 [9] 0.188 0.118 0.063 sat 15
Palest to darkest, Saturation rising to compensate the texture rather than acting as a weight.
There is no weight field — the list is the distribution — so reweighting means changing which
rungs occupy the ten slots. The original ladder is snapshotted, then the slots are refilled by
largest-remainder allocation across the rungs. Plain rounding would drop a rung that deserves a
slot but rounds to zero, which is the difference between rare and never. Slot count never
changes: the list is overwritten through its indexer rather than cleared and rebuilt, because an
il2cpp List<T> of a struct is a much riskier thing to resize from outside than to overwrite.
What is not known. Whether the generator picks a rung uniformly, or picks an Ethnicity
first (the class has a Caucasian/African/Asian/Other enum and three matching hair blacklists)
and indexes a slice. If it is the latter, reweighting shifts skin without shifting the ethnicity
the hair rules use. That is exactly why the default is inert — watch a park before trusting
it.
A read/write inspector for the running game, answered over loopback HTTP. On by default;
Probe/Port is 9099.
curl -s 'localhost:9099/find?type=VisitorBodyCustomizationOverride&limit=3'
curl -s 'localhost:9099/members?h=1'
curl -s 'localhost:9099/get?h=1&path=BodyRandomization.BreastSizeRange'
curl -s 'localhost:9099/set?h=1&path=BodyRandomization.BreastSizeRange&value=1,2'
curl -s 'localhost:9099/get?type=GameManager&path=Instance.StaffManager'/help lists the rest: /assemblies, /types, /statics, /component, /call, /drop.
Why it exists. Every expensive bug in this project has had one shape — a value described
rather than read. mod_breast_shrink was the chest size for three revisions because the story
was plausible; the presence of that same blendshape was how sex was detected, which cost a
release; BreastSize.currentValue was then taken for a 0..1 slider when its range is (1, 2),
so both ends normalised to "largest" and the reading stayed dead; the skin specular numbers are
still guesses. In every case the cost of finding out was: change code, rebuild, ask the user
to restart, have them play four minutes, read a log. That loop is why the guesses survived —
not because they were convincing. One curl ends it.
Threading is the whole design. il2cpp objects belong to the thread attached to the runtime,
and Unity API calls off the main thread are undefined. So the socket thread does nothing but
parse the request, queue it, and block; a postfix on GameManager.Update answers it on the main
thread and signals back. The consequence is worth knowing: the probe only answers while a park
is loaded. In the main menu GameManager does not tick, and requests return 504 saying so,
with the pump's frame count so you can tell "no park" from "wedged".
Writes go back through structs. Interop.Get on a struct field returns a boxed copy, so
mutating it changes nothing — and the tables this tool exists to reach are exactly that shape:
VisitorBodyCustomizationOverride.BodyRandomization is a struct holding Vector2 structs.
/set records every hop of the path and pushes the mutated box back up the chain until it
reaches a reference type, reporting structsRewritten so you can see it happened. Silently
skipping that would make this tool lie in precisely the way the size reading did. /set also
re-reads the path afterwards and returns before and after rather than claiming success.
Reach. Bound to 127.0.0.1, so nothing off this machine can connect. It is still an
unauthenticated arbitrary-reflection endpoint — reads, writes and method calls — for any process
on this machine. That is the right trade for a single-player debug tool and the wrong one for
anything else; turn it off in the config when not debugging.
It needs no other mod. UnityExplorer is a fine GUI for a human, but it cannot be queried from a shell, which is the entire point of this.