Skip to content

Latest commit

 

History

History
1447 lines (1151 loc) · 86.8 KB

File metadata and controls

1447 lines (1151 loc) · 86.8 KB

Features in detail

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.

StaffCapacity

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.CurrentMaxStaffCapacity is a NetworkVariable<int>.
  • StaffManager.Initialize seeds it from the difficulty's InitialStaffCapacity, walks GameSettings.Research.StaffCapacityUpgrades, and calls ExpandStaffCapacity() once per upgrade already researched. For upgrades not yet researched it subscribes a listener that calls ExpandStaffCapacity() when they unlock.
  • ExpandStaffCapacity() is exactly Value = 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.

StaffExperience

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 ActionCompletedEvent carrying an EEventSourceLocalPlayer(1), RemotePlayer(2), Staff(4), Visitor(8). Forty-one classes raise it; StaffCleaningInteraction, StaffRepairingInteraction and StaffPushBadVisitorInteraction pass Staff.
  • OnActionCompleted turns the action name into XP with ResearchSettings.GetReward(name), scales that by the difficulty's research-reward multiplier, plays the local XP sound if the source was LocalPlayer, and then — behind an IsServer check — multiplies again by ResearchSettings.staffReserachMultiplier if and only if the source is exactly Staff, before calling AddExperience. 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.

GuestSwimwear

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:

  1. destroys the old piece and spawns the new mesh under ApparelParent;
  2. when slot == 0, writes _Neck_Shrink (from Apparel.NeckShrink) to materials M_CC_Head and M_CC_Body — this shrinks body geometry so it can't poke through the garment;
  3. writes table.SkinMaskProperty with Apparel.Mask to three materials — M_CC_Head, M_CC_Body and FirstPersonBody — masking the skin away underneath;
  4. when Apparel.FootOffset.HeightOffset >= 0, calls setBodyCustomization three times with the garment's FootRotation, BallRotation and HeightOffset — so a garment can reshape the body through ModifyBone, not just mask it. (Found by resolving setApparel'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.customizationSettingsFemaleSwimsuitOutfitsTableOutfits[].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.

Diagnosing a body that still looks squished

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 — fix Unequip;
  • 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 drive setBodyCustomization ourselves.

The second is what "macho upper body, twiggy legs on buff male guests" sounds like, but the dump is what decides it.

Who is in scope

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.

Player avatars

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]SystemsCharacterCustomiserUtility, 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.

WardrobeNone

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.

Why this is a raw memory write

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.

StaffMarker

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.

Finding the employees

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.

Nameplate

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.

Hat and Sunglasses

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 (default true) additionally requires both body slots to be bare — the same IsUndressed test GuestSwimwear uses before dressing an employee again. Set it false to 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.

The interaction with GuestSwimwear

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.

Unverified

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 on GameManager before it spawns anything, and what that gate is has not been read. If plates never appear, Nameplate is silent and does nothing.
  • Whether Cap_Security is in the Hats apparel table and not some security-only one. It is referenced by Staff_Security_F and Staff_Security_M against 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 in HatNames is used and the log says which.
  • Whether TMP_Text.text reached 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.

Wetness

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

Why skin still reads as plastic when it is dry, and why it reads as metal

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

  • SpecularTintScale scales the tint's brightness after TintToSkin has coloured it. This is the metal knob. Lower it until characters stop reflecting their surroundings.
  • SmoothnessMax is an absolute ceiling on _Skin_Smoothness, applied after the Smoothness multiplier. 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.

Reading the values before turning the knobs

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"

Jiggle

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 spring is driven by acceleration, not by chasing a position

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 Stiffness and Damping were 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.

What the drive is actually measuring

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. MaxAcceleration was 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. TeleportAcceleration discards 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. dt was min(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.

Stiffness and MaxOffset are one number, not two

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

Gravity is already in this, and it is not what makes up and down look alike

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.

The three axes do not deserve equal travel

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.

Telling the sexes apart: ask the game, not the meshes

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.

mod_breast_shrink is not a size reading

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.

currentValue is the lerped output, not the slider — and that killed the reading twice

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.

Size has to change the travel, not just the frequency

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.

Weight at rest

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.

Reading a motion report

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.

BodyMesh

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 materials array,
  • blendshapes are driven by BlendshapeManager by 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.

DayLength

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.

VisitorVariety

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.

VisitorSexRatio

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.

Two approaches that failed, and the rule that came out of them

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: with FemaleShare = 1 a park came out 18 female to 27 male. Harmony 2.10.2's __args write-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 AIBrain failed at patch time with IL Compile Error (unknown location), which names neither the parameter nor the reason. Four signatures were tried — IntPtr __result and Il2CppRef __result, each with and without object[] __args — and all four were rejected identically. Il2CppInterop's detour patcher cannot inject __result for 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, and Il2CppRef __instance on a void method is unaffected.

The rule both point at: patch where a value is read, not where it is written.

Visitors

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.

Hireable staff

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.

Why nothing is written

Two attempts, both instructive, the second destructive.

  • Rewriting the hire card. StaffCardUI.ConnectedStaffInfo is a real copy of the offer and is what the hire flow reads, so it looks like the right target. It is written by RebuildHireableStaffEntries, which runs after this postfix — and GenerateDailyHireableStaff opens with ReleaseAllHireCards(), 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.DailyHires is the real store. Interop.SetAt wrote 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 of Stats; 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 reading 103400839 $1123858936/day and 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.

Answering the draw instead

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.

VisitorSkinTones

Reweights the skin tone the generator draws from. Weights, default 1,1,1,1,1,1,1,1,1,1identical 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.

Probe

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.