Skip to content

Coverage: what the client asks for against what the emulator answers

From the aowlspt repository — docs/EMULATOR-COVERAGE.md

Published from aoughwl/aowlspt with only its links repaired. It was written for someone with the checkout open, so it is terse and points at source files by path — browse the tree as you read. A few of the documents it cites were held back from publication and are not here.

The manual · The aowlspt section

The gap list for the emulator: every operation the real client can ask for, and whether this backend answers it.

This is the gap list for mods/tarkov, built from reference/ rather than from memory. EMULATOR.md says what the emulator does; this says what it does not, which is the more useful half when the question is "why did the client do that".

How it was derived

The reference dump is metadata only — types and members, no route strings — so the two halves of the map come from two different places and are joined by hand.

The demand side is the Callbacks classes. Every request the Tarkov client can make reaches one method on one of them, so the method list is the complete surface:

awk '/^class SPTarkov.Server.Core.Callbacks\./{i=1}
     /^static class SPTarkov.Server.Core.Constants/{i=0} i' \
    reference/spt-4.1-surface.txt |
  grep '^    ' | grep -v '^    ctor' |
  grep -vE 'OnLoadAsync|OnUpdateAsync|static |Void Send|Void Process'

That is 226 methods. The same dump gives the request and response shapes: a callback's second parameter names its request DTO and the controller behind it names the response DTO, and both are in SPTarkov.Server.Core.Models.Eft.* a few thousand lines further down. Those DTOs are what "shaped right" below is measured against — property by property, not by eye.

The one thing the dump does not carry is the URL each callback is bound to, because the router registrations are code and this is a metadata dump. The URL column is therefore the well-known client path, and where a path could not be established from the dump it is marked (url unverified) rather than guessed silently.

The item-event actions — everything the player does by dragging something, all of it down one endpoint — come from the same file:

awk '/Models.Enums.ItemEventActions/{i=1} /Models.Enums.ItemTpl/{i=0} i' \
    reference/spt-4.1-surface.txt | grep '    field String'

55 of them.

The supply side is the mod:

grep -oE 'serve(Prefix)?\("[^"]+"' mods/tarkov/tarkov.nim   # 132 routes
grep -rn 'of "' mods/tarkov/emu/*.nim                       # 156 action arms

Re-run all four and the table below can be rebuilt — but nobody has to. tools/coverage.nim computes all four by reading the same files in nimony, and writes them into this document between explicit markers. The four commands are kept here because they are how a person checks the tool, not because they are how the document is maintained:

aowl-coverage            rewrite the generated regions of this file
aowl-coverage --check    write nothing; fail if they are stale

aowl test runs the second one.

Headline

Of the 226 callback methods, 15 are not client requests at all — the SPT launcher (LauncherV2Callbacks, 11), mod bundles (BundleCallbacks, 2) and the mod loader (ModLoaderCallbacks, 2) are that server's own plumbing and have no counterpart here. That leaves 211 client-facing operations, and the buckets are:

operations
served, and shaped against the reference DTO132a route or an item-event arm of its own
served, deliberately empty or flattened20answered by a stock stub -- onNullData, onEmptyObject, onEmptyArray or onTrue -- and named individually below
not served59404s, and the client retries or does without
client-facing operations211the 226 callback methods less the 15 in LauncherV2Callbacks, BundleCallbacks, ModLoaderCallbacks, which are that server's own plumbing

That table is generated. tools/coverage.nim writes it, out of docs/coverage-rows.json — one row per callback method — and out of mods/tarkov/tarkov.nim itself. Nobody edits those three numbers any more, and aowl test fails if they are not what the code says. Read the rest of this section for what the numbers mean; do not read it for what they are.

The hand-joined passes before the generator read 127 / 24 / 60, then 122 / 25 / 64, 119 / 26 / 66, 117 / 27 / 67 and 107 / 28 / 76. The first run of the generator moved the split by three operations and every one of them is a correction rather than a change to the emulator:

  • Match.PutMetrics and Match.EventDisconnect had no row at all. Both are served, both by a stub, so the empty bucket gained two.
  • Game.GetCurrentGroup had no row either, and nothing in tarkov.nim binds it, so the not-served bucket gained one.
  • Dialogue.SetRead, PinDialog, UnpinDialog, RemoveDialog, Match.ExitFromMenu, Match.ExitMatch, Profile.GetProfileSettings and Ragfair.StorePlayerOfferTaxAmount were all counted as shaped and are all bound to onNullData or onTrue. Reading mail does not mark it read and pinning a dialogue does not pin it. That is eight operations moving from shaped to empty, against two moving the other way — Match.GroupCurrent and GetGroupStatus, which the hand join called empty and which answer a real onMatchGroup shape with an empty squad in it.

The pass before the generator also closed five operations, all of them the hideout's own wardrobe and the flea's renewal: HideoutCustomizationApplyCommand, SetMannequinPose, RecordShootingRangePoints and ExtendOffer were not served and now are, and GetHideoutCustomisation moved out of the empty bucket into the shaped one because it now answers hideout.customisation — 38 globals and 47 slots — as the reference's object rather than as [].

Seven operations this file once recorded as blocked on data have now been closed by reading build/db/db.json rather than by importing anything, over three passes: RestoreHealth and ScavCaseProductionStart, then HandleQTEEvent and SetCustomisation, and now HideoutCustomizationApplyCommand, SetMannequinPose and RecordShootingRangePoints. Not one of them needed an importer row. The pattern is worth stating once more where the gap list lives: a gap gets written down with a reason, the reason is never re-checked against the data, and the sentence outlives the fact. Before scheduling anything in the not served column, grep build/db/db.json for the table it claims to need.

Two of the four things this pass closed do not move a number at all, and that is worth stating rather than hiding: the root-container hole in Move and Swap is a refusal added to operations already counted as served, and bot loot and TraderRepair.ExcludedCategory are content and a rule inside operations already counted as served. A coverage table counts surfaces; three of these four were holes behind a surface that was already there.

Those three numbers are docs/coverage-rows.json, summed by tools/coverage.nim. The tables in the rest of this file group operations for reading — GetHideoutAreas / GetHideoutProduction / GetHideoutSettings is one row for three — so they are prose about the split rather than the split itself, and they are not what the headline is summed from. They were never able to be: they carry 174 operations between them where the reference has 211, and GetRaidTime appears in two of them.

How the split is arrived at, since a headline that cannot be re-derived is a rumour. The three buckets are not stored anywhere. docs/coverage-rows.json names, per callback method, the routes and the item-event actions that answer it — that join is the one thing no sweep can produce — and tools/coverage.nim works the bucket out from the code every run: a row naming no route and no action is not served; a row naming no action whose every route is bound to a stock stub — onNullData, onEmptyObject, onEmptyArray, onTrue — is served, deliberately empty; everything else is served and shaped. Rebinding a route from a real handler to onEmptyObject moves the headline on the next run with nobody editing a number, and aowl-coverage --check in aowl test fails if the document on disk has not been rewritten.

It cross-checks in both directions as well, which is the half that catches a row somebody forgot: every route a row names must be registered in tarkov.nim, every route tarkov.nim registers must be named by a row (or exempted by name in nonClientRoutes, and every exemption must be a route that really exists), every action a row names must appear as a string literal in mods/tarkov/emu/*.nim with comments stripped, and every Callbacks method in the reference must have exactly one row and no row may name a method the reference does not have. docs/BACKLOG.md carried the hand rebuild as B11.

The distinction that decides priority is not served/absent. It is whether a served route answers a shape the client can read. A route that returns {"err":0,"data":null} where a populated object is expected does not fail at the request — it fails three screens later, on a null the client dereferenced because the server told it everything was fine. Five of those were found and closed in this pass and are marked was mis-shaped below.

Boot and session

OperationURLStatus
GetGameConfig/client/game/configserved
GameStart/client/game/startserved
GetGameMode/client/game/modeserved — added
VersionValidate / ValidateGameVersion/client/game/version/validateserved, accepts any build on purpose
GameKeepalive/client/game/keepaliveserved
GameLogout/client/game/logoutserved
GetServer/client/server/listserved
GetVersion/client/checkVersionserved
GetChatServerList/client/chatServer/listserved — added
GetProfileStatus/client/profile/statusserved — added
GetProfileSettings/client/profile/settingsserved — added
PutHwMetrics/client/putMetricsserved, discards
GetMetrics/client/getMetricsserved, empty
ClientLog / BsgLogging / ReleaseNotes/client/log, /client/bsgLogging, /client/releaseNotesserved, discards
GetSurvey / GetSurveyView / SendSurveyOpinion/client/survey, /client/survey/view, /client/survey/opinionserved, no survey
ReceiveClientMods(url unverified)not served — SPT tells the client which client mods are loaded; there are none
ReportNickname(url unverified)not served — reporting a player on a single-player server
GetRaidTime(url unverified)not served — see Raids

Profiles

OperationURLStatus
GetProfileData/client/game/profile/listserved
CreateProfile/client/game/profile/createserved
ValidateNickname/client/game/profile/nickname/validateserved
GetReservedNickname/client/game/profile/nickname/reservedserved
ChangeNickname/client/game/profile/nickname/changeserved
ChangeVoice/client/game/profile/voice/changeserved
RegenerateScav/client/game/profile/savage/regenerateserved
SearchProfiles/client/game/profile/searchserved, empty
GetOtherProfile/client/profile/viewserved, empty
SelectProfile/client/game/profile/selectserved
GetAllMiniProfiles / GetMiniProfile(launcher)not served — the launcher's, not the client's

Static tables

Every one of these is a dbRead with a valid-empty fallback.

OperationURLStatus
GetTemplateItems/client/itemsserved
GetGlobals/client/globalsserved
GetTemplateHandbook/client/handbook/templatesserved
GetTemplateSuits/client/customizationserved
GetCustomisationUnlocks/client/account/customizationserved, empty
GetLocalesLanguages/client/languagesserved
GetLocalesGlobal/client/locale/<lang>served
GetLocalesMenu/client/menu/locale/<lang>served
GetSettings/client/settingsserved
ListQuests/client/quest/listserved
GetAchievements/client/achievement/listserved
Statistic/client/achievement/statisticserved — was mis-shaped, {} where CompletedAchievementsResponse is {elements:{}}
GetPrestige/client/prestige/listserved — was mis-shaped, [] where GetPrestigeResponse is {elements:[]}
GetItemPrices/client/items/prices/<traderId>served — added, {supplyNextTime, prices, currencyCourses}
GetTemplateCharacter(url unverified)not served — the starting-profile templates; this server synthesises its own
GetQteList/client/hideout/qte/listserved — was empty; answers hideout.qte, one entry of fifteen events
GetDialogue(url unverified)not served — SPT's chat-command templates, templates/dialogue.json (6.26 MB), which the importer does not bring. Not to be confused with traders/<id>/dialogue.json, which is imported and is read: emu/dialogue composes the insurance mail out of it, see Insurance below
GetHideoutAreas / GetHideoutProduction / GetHideoutSettings/client/hideout/*served

Traders and trading

OperationURL / actionStatus
GetTraderSettings/client/trading/api/traderSettingsserved
GetTrader/client/trading/api/getTrader/<id>served — added
GetAssort/client/trading/api/getTraderAssort/<id>served
(user assort price)/client/trading/api/getUserAssortPrice/<id>served, empty
GetStorage/client/trading/customization/storageserved — was mis-shaped, [] where the response is {_id, suites:[]}
GetTraderSuits(url unverified)not served — clothing for sale; needs trader/<id>/suits.json, which the importer does not bring
ProcessTradeitem event TradingConfirmserved, priced by the assort
ProcessRagfairTradeitem event RagFairBuyOfferserved
SellAllFromSavageitem event SellAllFromSavagenot served — and not for want of data. Refused by name, with the reason on the response: emu/scav.refuseSellAll. The handbook prices are loaded, Fence is in traders with his PriceModifier and his loyalty levels, and emu/mail can pay roubles. What is missing is the itemsendScavRaid has already moved everything a surviving scav carried into the PMC stash at /client/match/local/end, which the client posts before it draws the screen this button is on, and the scav has no stash of its own by design, so "everything in the scav's inventory" is the player's whole stash spliced in. Its one member, TotalValue, is the client's arithmetic and is logged rather than paid
Repair / TraderRepairitem events Repair, TraderRepairserved — added; see Repair and durability below
SetCustomisationitem event CustomizationSetserved — added; a validated write, see The wardrobe below
BuyCustomisationitem event CustomizationBuynot served — the price and the stock are in trader/<id>/suits.json, which the importer does not bring

The wardrobe

templates.customization — 728 entries — has always been served whole on /client/customization, and nothing could ever change anything: the wardrobe screen drew every suit in the game and Apply did nothing. CustomizationSet is now a validated write in emu/customise, and the validation is the feature rather than a wrapper around it, because this is a write the client asks for by id.

The request is CustomizationSetRequest{customizations: [{id, type, source}]} — and type is one of the reference's CustomisationType constants. Four of them are the player's: head, suite, dogTag and voice. Applying one is a join, not a copy: a suite entry carries no clothing of its own, its _props name a Body and a Hands entry (or a Feet one, for a lower suite), and each of those is checked before either is written.

Refused by name, never written and never partly written:

  • an id the table does not have;
  • an id of the wrong body part — _props.BodyPart against the slot;
  • an id belonging to another side — _props.Side against Info.Side, and an entry with an empty Side (four exist: mannequin dressing and cultist voices) is refused as available to nobody;
  • an id gated on another edition — the five entries with a non-empty _props.ProfileVersions, against Info.GameVersion;
  • a type this server does not set. The hideout's nine (floor, wall, light, mannequinPose, …) belong to HideoutCustomizationApply and its CustomisationUnlocks list, neither of which is served; saying so beats a room that did not change and no reason given.

Two things are deliberately not checked and both are stated in the module header rather than left to be found:

  • The parts a suite points at are not re-checked against the player's side. KillaUpperSuite is available to all three sides and the body and hands it names are Savage only — one entry in 165, and refusing it would refuse a suite the game offers.
  • Whether the player has unlocked the thing. Unlocks live in CustomisationUnlocks, filled by BuyCustomisation and by quest rewards; this server has neither, because trader/<id>/suits.json is not in the imported database. So anything the table has and the side allows can be worn. That is more permissive than the game, and it is a stated gap.

The starting Customization block was scrambled, and this is what found it. Against the real table: Head was DefaultBearHead for Usec characters too; Body was DefaultUsecBody for Bear and DefaulUsecFeet — a foot — for Usec; Feet was DefaultUsecHands for Bear and DefaultUsecBody for Usec; Hands was DefaultBearHands for both; and DogTag was an item template id on both sides. Seven of the eight ids were wrong and four named the wrong body part outright. Nothing ever failed: the client resolves these against its own bundles, so the symptom is a character that renders wrongly, which reads as a mod problem rather than a server one. The ids now live in defaultCustomisation in emu/profile and selfCheckCustomisation runs them through the same validator a CustomizationSet goes through — so a scramble of that kind stops the mod loading rather than shipping.

Repair and durability

upd.RepairableDurability and MaxDurability, the reference's UpdRepairable — is now tracked, restored and degraded.

The client drives the wear, and that is established rather than assumed./client/match/local/end carries the profile the client played the raid with and the server saves that document, so a rifle that lost 23 points of durability in a raid arrives with the loss already written. There is no callback in the reference for the server to apply wear from: no per-shot or per-hit route, and no wear action in ItemEventActions. Applying a percentage at raid end — the obvious design — would apply it on top of what the client already applied, and the two are indistinguishable in the document that arrives.

WhatWhere it comes from
a trader's price_props.RepairCost per point × points × the loyalty level's repair_price_coef as a percentage, rounded up
a kit's costRepairSettings.DurabilityPointCostGuns resource units per point on a firearm; DurabilityPointCostArmor × armorClass / ArmorClassDivisor on armour
the permanent damage_props.Min/MaxRepairDegradation (trader) or Min/MaxRepairKitDegradation (kit), × the points restored, off MaxDurability
a kit's chargeupd.RepairKit.Resource, filled from _props.MaxRepairResource the first time it is used

The reference gives every one of those names and none of the arithmetic that joins them, because a metadata dump carries no method bodies. The readings above are written out in the header of emu/repair.nim so that they can be disagreed with rather than reverse-engineered. A template that names no RepairCost is refused, not repaired free; a template that names no degradation rate degrades by nothing, because "the database does not say" must not become an invented penalty on somebody's gear.

Two things are deliberately not done and are named here rather than left to be found:

  • A repair is charged in roubles whatever the trader's currency says. Converting needs a rate, and the only rate in this database is a currency item's handbook price, which prices a stack of notes rather than an exchange.
  • TraderRepair.ExcludedCategory is evaluated — it used not to be. It names handbook categories, and the walk is the whole point of it: a trader excludes a branch, and on real data the ids it names sit several levels above the category an item's own handbook entry is filed under. Matching only that entry's category refuses nothing at all, and passes a check written against the leaf. handbookCategories in emu/templates walks the item's entry up ParentId to the root of the tree, bounded by the size of the category table because ParentId is data and a loop in it must return a list rather than spin. An item the handbook has no entry for is not refused: its categories are unknown rather than empty, and a trader with one exclusion would otherwise refuse the whole game on a database with no handbook. ExcludedIdList is evaluated too, as it always was. Both are read under the reference's spelling and the database's (excluded_id_list, excluded_category); the wire spelling is (unverified).

And one gap that looks like an oversight and is a decision: a client that hands back a profile with the durability put up is not caught. It is the same trust the rest of the raid result already gets, and closing it means diffing every item's upd against the pre-raid profile — a different piece of work.

Trader loyalty was the largest gap on this screen and is now closed: TraderInfo.salesSum accumulates the rouble value of every purchase and sale, and loyaltyLevel is re-derived from the trader base's loyaltyLevelsminLevel, minSalesSum, minStanding, all three — after every move. Purchases are refused above the level loyal_level_items gates them behind. Standing is still moved only by quest rewards: the reference gives the three requirements a level has and says nothing about what a purchase is worth in reputation, and a rate invented here would be a number nobody can check.

The inventory

All of these are one endpoint, /client/game/profile/items/moving, and the Action field picks the arm.

ActionStatus
Move, Split, Merge, Transfer, Remove/Discard, Swapserved — no move may make an item its own ancestor, and none may give one of the profile's root containers a parent
Fold, Toggle, Tagserved
Examine, ReadEncyclopediaserved
Move with fromOwner: Mailserved — mail redemption
Insureserved — added; see below
SetFavoriteItemsserved — added
PinLockserved — added
Bind, Unbindserved — added
AddToWishList, RemoveFromWishList, ChangeWishlistItemCategoryserved — added
Heal, Eatserved — added
RestoreHealthserved — added; see Healing at a trader below
Repair, TraderRepairserved — added
ApplyInventoryChangesserved — on a whitelist; the shape is (unverified)
RepeatableQuestChangeserved — added; counted under Quests rather than here, where it is described
AddNote, EditNote, DeleteNoteserved — added
CreateMapMarker, EditMapMarker, DeleteMapMarkerserved — added
OpenRandomLootContainernot served
RedeemProfileRewardnot served — the launcher's promo rewards
SaveDialogueStatenot served
SaveWeaponBuild, SaveEquipmentBuild, RemoveBuild and friendsserved as routes, not as item events — see Builds

Insurance was quoted and could not be bought./client/insurance/items/list/cost has always answered a premium per item, and emu/insurance has always returned exactly the insured items that did not come home — but Insure was an unhandled action, so InsuredItems was empty on every profile forever. The quote screen worked, the money was never taken, and nothing ever came back. That is closed: the premium is charged out of the player's own stacks (the request carries no scheme_items, so the server picks them), an item is never charged for twice, and the whole thing is verified before any of it is taken.

And the letter is now the trader's own. traders.<id>.dialogue is a map of situation → list of locale ids, and it is real data in this dump: Prapor has seven lists, Therapist seven, Fence one and the BTR driver one. Nothing read any of it — emu/insurance posted the same hardcoded English sentence for every trader and every outcome, which is the one thing a player cannot get from the real game. emu/dialogue resolves it, and it is built around three rules:

  • Nothing is invented. A trader with no list for a situation, a locale id that resolves to nothing, or a line whose {location} / {date} / {time} placeholders cannot all be filled answers ok: false, and the caller falls back to the plain sentence. An empty message and one reading "lost somewhere on {location}" are both worse, and both were reachable from a naive version.
  • The choice is reproducible. Traders pick at random from a list of up to seven; the pick is emu/rand seeded from what the store holds — profile, trader, situation and the second the raid ended — so a delivery that fails to save and is retried says the same thing the second time, and a test can predict the line. There is no global entropy anywhere in this emulator and this does not add any.
  • The language is the client's. The line is resolved against whatever language the client last asked /client/locale/<lang> for, at the moment the message is sent, falling back to English the way templates.locale does. Resolving to English once and storing it would be wrong forever rather than for one request. Putting the locale id in templateId and letting the client resolve it — what BSG's own server does — is deliberately not done: the client fills the placeholders from a systemData shape this project has no dump of, and a guess renders as literal {date} in the player's inbox. Substituting server-side is the half that can be verified.

This is also the only place in the emulator that formats a date, which is why emu/dialogue carries Howard Hinnant's civil_from_days and selfCheckDialogue pins it.

Healing at a trader

This was recorded as blocked on a table that is in the database. Both this file and docs/BACKLOG.md said RestoreHealth "needs the treatment price table". It is globals.config.Health, and it is complete:

whatwherelive value
a hit pointHealPrice.HealthPointPrice30
a point of energyHealPrice.EnergyPointPrice0
a point of hydrationHealPrice.HydrationPointPrice0
removing an effectEffects.<name>.RemovePriceFracture 1000, LightBleeding 400, HeavyBleeding 1200, BreakPart 1000, Intoxication 42700
the trader's markuployaltyLevels[n].heal_price_coefTherapist 100 / 110 / 120 / 135; every other trader 0

Four decisions are written into emu/health.nim's header rather than left to be inferred, and each of them is a refusal or a bound:

  • A database with no HealPrice refuses. Free healing is not the same answer as "the price is not known" — the same rule emu/repair applies to a template with no RepairCost.
  • An effect with no RemovePrice is refused by name. Five effects in a live table carry one; Contusion, Dehydration and the rest do not, because they are not things a trader treats. And the refusal is whole: hit points bundled into the same request as an untreatable effect are not treated either, because a partial treatment is a bill the player cannot reconcile with what they clicked.
  • Zero is "not named", not "free". Every trader but Therapist carries a literal heal_price_coef of 0 on every loyalty row, because none of them heals. Read as a multiplier that would make healing at Prapor cost nothing; it reads as 100 — full price — instead.
  • The request's items are not used to price anything. The reference puts a payment scheme on HealthTreatmentRequestData and believing it would let a client name its own price. The cost is computed from the table and taken out of the player's own loose roubles by spendCurrency, exactly as an Insure and a TraderRepair are.

And the module's existing bound applies to the third event as it does to the first two: a treatment is priced for the damage that is actually there. A client asking to restore 200 points on a leg missing 40 pays for 40, and an effect the profile does not carry is neither charged for nor invented.

One reading is marked (unverified) and is not hidden: the direction of heal_price_coef. Therapist's rises with loyalty (100 → 135) where a discount would fall, and the reference gives the member and not the arithmetic. It is read directly, as a percentage, because that is how repair_price_coef is read in emu/repair and that direction is established by Fence's 300. At loyalty level 1 the coefficient is 100 and the two possible readings agree exactly, which is where most treatment happens.

Builds

OperationURLStatus
GetBuilds/client/builds/listserved — was mis-shaped, {} where UserBuilds is three lists
SetWeapon/client/builds/weapon/saveserved — added
SetEquipment/client/builds/equipment/saveserved — added
CreateMagazineTemplate/client/builds/magazine/saveserved — added
DeleteBuild/client/builds/deleteserved — added

Kept under their own store key rather than in the profile, because /client/match/local/end replaces the profile with the client's copy of it and a list the client did not touch would come back as whatever the raid's copy happened to be.

Raids

OperationURLStatus
GetRaidConfiguration/client/raid/configurationserved
GetLocationData/client/locationsserved
(local loot)/client/location/getLocallootserved — generated
GetAirdropLoot/client/location/getAirdropLootserved — added; answers {icon, container}, and the crate is empty until the database has an airdrop loot table
StartLocalRaidAsync/client/match/local/startserved
EndLocalRaidAsync/client/match/local/endserved
ServerAvailable/client/match/availableserved
GroupCurrent / GetGroupStatus/client/match/group/current, /client/match/group/statusserved, empty squad
ExitFromMenu / ExitMatch/client/match/group/exit_from_menu, /client/match/exitserved — added
GetWeather / GetLocalWeather/client/weatherserved — out of the database's weather when it has one, one dull record when it does not
GetConfigurationByProfile(url unverified)not served
GetRaidTime(url unverified)not served — see below
The 15 group and matchmaking calls (JoinMatch, SendGroupInvite, RaidReady, UpdatePing, …)not served
GetBossTypes, GetRaidMenuSettings, GetTraitorScavHostileChance, RegisterPlayer, SaveProgress(SPT singleplayer routes)not served

Raid timing and exit points are the client's here. The map's EscapeTimeLimit is taken from the database's location base and handed over with the floor; the exit points themselves come out of the client's own bundles and the server is never asked which ones opened. Nothing in the reference gives this server a way to decide that, so it does not pretend to.

Bots and scavs

OperationURLStatus
GenerateBots/client/game/bot/generateserved
GetBotLimit/client/game/bot/limitserved
GetBotDifficulty/client/game/bot/difficultyserved
GetAllBotDifficulties, GetBotCap, GetBotBehaviours(url unverified)not served — SPT's own configuration routes

Bots wear what the bot tables dress them in — which they did not until this pass. The generator read three fields out of bots.types.<role> (firstName, health.BodyParts[0] and the head of each appearance list) and never touched inventory at all, so every bot in every raid spawned with exactly two items: an empty equipment container and an empty stash. inventory.equipment is now rolled per slot against chances.equipment, inventory.mods is resolved recursively against chances.weaponMods / chances.equipmentMods, and a magazine is filled from inventory.Ammo keyed by the weapon's own _props.ammoCaliber to the magazine's own _props.Cartridges[0]._max_count.

Three details of that were read off a real 41 MB database rather than off the fixture, and all three would have been guessed wrong:

  • the chance tables are chances.equipment, chances.weaponMods and chances.equipmentModsnot one chances.mods;
  • a mod slot name appears in either casing in stock data (Helmet_top on 59 items, helmet_top on 3), so a chance looked up by exact key silently misses and the slot falls back to its default of "always";
  • the value under an equipment slot is a weight map of template id to weight, not a list, and a weight of zero is common and means never.

/client/game/bot/limit was the literal 30 for every map with no database path behind it; it now reads the location's BotMaxPvE, then BotMax, and falls back to a configured default. /client/game/bot/difficulty answered bots.core whatever it was asked; it now answers bots.types.<role>.difficulty.<level> and falls back to bots.core.

A bot's loot — what it is carrying as opposed to wearing — is generated too, and was the last reason a raid's rewards were thinner than the real game's: a scav you killed had a rig and a rifle and nothing whatever in its pockets. It is a second generator over two more tables, both read off the same real database:

  • inventory.items has one member per container slot — TacticalVest, Pockets, Backpack, SecuredContainer, SpecialLoot — and each is a weight map of template to weight, exactly as inventory.equipment is. A plain scav's Backpack pool has 1,851 entries in it.
  • generation.items.<kind> is {weights: {"0": 1, "1": 6, …}, whitelist: []}, where the key is a count and the value is how likely that count is. A weight on a count of zero is common and means "none of this kind", which is why a count is drawn rather than assumed.

Each item is placed in a real cell of a real grid of the container it goes in, first fit, both orientations, across every grid the container has — a rig has four grids of one or two cells and a location written without checking them is two items drawn on top of each other that the player cannot pick up. A container with no room gets nothing rather than an overlapping location.

Three of the twelve kinds are generated and nine are not, for one reason.backpackLoot, pocketLoot and vestLoot name a container, and that container has a pool in inventory.items under the same name — so the count and the pool it applies to are both in the data. The other nine (healing, drugs, stims, food, drink, currency, grenades, magazines, specialItems) name a category of item, and nothing in the database says which templates those are: it is the item's base class, walked up _parent to one of a set of well-known class ids, and that set is the real server's source code rather than its data. Writing it out here from memory is exactly the kind of guess that reads as data afterwards and is not. SecuredContainer and SpecialLoot have pools and no count kind naming them, so they are left empty for the mirror-image reason.

Fence reputation — the scav's karma — is modelled now, with the rate as a setting. The reference gives Fence exactly the same shape as every other trader (TraderInfo.standing gating TraderLoyaltyLevel.MinStanding) and gives no rate at all, because the rate is a balance number that lives in the real server's own config rather than in the database. So:

settingdefaultwhat it is
fenceKarmaOnScavExtract0.01standing for walking out of a scav raid
fenceKarmaOnScavDeath0.0standing for dying in one

0.01 is this file's choice, not a figure recovered from the game: it is chosen so that a hundred successful scav runs move the standing by 1.0, which is the granularity Fence's loyalty levels are written at in a live trader base. Zero for a death because the reference gives no penalty and inventing one takes something from a player that nothing can be pointed at to justify. Either can be set to zero to turn the system off.

The flea market

OperationURL / actionStatus
Search/client/ragfair/findserved
GetMarketPrice/client/ragfair/itemMarketPriceserved
StorePlayerOfferTaxAmount/client/ragfair/offerfeesserved — added; this server prices a listing from the offer itself, so there is nothing to remember
SendReport/client/reports/ragfair/sendserved — added, discards
AddOfferitem event RagFairAddOfferserved
RemoveOfferitem event RagFairRemoveOfferserved
ExtendOfferitem event RagFairRenewOfferserved — added; the offer must be the player's, must not have expired, and the extension may not exceed maxRenewOfferTimeInHour. Free, and deliberately: this market takes no listing fee, and RagFair.renewPricePerHour (0.5 here) is not said anywhere to be roubles-per-hour rather than a percentage of the asking price — two readings four orders of magnitude apart, so neither is invented
GetFleaOfferById(url unverified)not served
GetFleaPrices(url unverified)not served

The hideout

OperationActionStatus
Upgrade / UpgradeCompleteHideoutUpgrade, HideoutUpgradeCompleteserved — and it now costs something and takes time
ToggleAreaHideoutToggleAreaserved
PutItemsInAreaSlots / TakeItemsFromAreaSlotsserved
SingleProductionStart / ContinuousProductionStartserved
TakeProductionserved
HideoutDeleteProductionCommandHideoutDeleteProductionCommandserved — added; the inputs are not returned
CancelProduction(url unverified)not served — the route form of the same thing
ScavCaseProductionStartHideoutScavCaseProductionStartserved — added; see The scav case below
CicleOfCultistProductionStartnot servedhideout.production.cultistRecipes imports as a single entry carrying nothing but an _id, so there is no table behind it to answer from
HandleQTEEventHideoutQuickTimeEventserved — added; see The gym below
RecordShootingRangePointsHideoutRecordShootingRangePointsserved — added; see The hideout's wardrobe below
HideoutCustomizationApplyCommandHideoutCustomizationApplyCommandserved — added; see The hideout's wardrobe below
SetMannequinPoseHideoutCustomizationSetMannequinPoseserved — added; see The hideout's wardrobe below
GetHideoutCustomisation/client/hideout/customization/offer/listserved — was empty; answers hideout.customisation as the reference's object of Globals (38) and Slots (47), not as an array

The gym

hideout.qte is one entry — area 23 at level 1, fifteen quickTimeEvents, a requirements list and a results block — and it was recorded as a content gap while sitting in the database all along. GetQteList now answers it and HideoutQuickTimeEvent acts on it, in emu/gym.

The whole design question is what a server is entitled to believe. The request is {results: [bool], id, timestamp}; whether any individual circle was actually hit happens entirely in the client and is never checked, which the module says in place rather than leaving a reader to wonder. Everything around the booleans is checked before a point of Strength is paid:

  • the profile has area 23 at the level the entry names;
  • the entry's own requirements: 30 energy, 30 hydration, and no Fracture on either arm. This is the real limiter — a run costs 2 energy and 2 hydration per hit and 4 per miss, so a full fifteen-event workout costs 30 to 60 of each and the floor means the gym cannot be run without food and water;
  • results is no longer than the fifteen events the entry defines;
  • a false may only be the last element, because singleFailEffect.rewardsRange[].result is "Exit" — the database saying a missed circle ends the session. A short all-true run is allowed: a player can walk away, and the finish bonus simply does not apply.

An unrecognised requirement type is a refusal, not a pass, for the reason the free-hideout bug above gives.

Skill comes off singleSuccessEffect.rewardsRange: Endurance and Strength, each with a levelMultipliers table of 0 → 6, 10 → 5, 25 → 4, joined against the level the profile currently has and multiplied by the number of hits. It goes in through addSkill, so the ordinary curve — fresh-skill bonus, fatigue, the per-raid cap — prices it rather than a second copy of that arithmetic. Both skills are paid, not one of two by weight; the two entries differ in nothing but skillId and the gym trains both, and the decision is recorded in the module header as the one to revisit if a table ever carries unequal weights.

Two penalties in the data are not applied, and the player is told so on the response. finishEffect names a reward of type MusclePain and singleFailEffect one of type GymArmTrauma. Neither string exists anywhere else in a 39 MiB database: globals.config.Health.Effects defines MildMusclePain and SevereMusclePain and nothing called MusclePain, and GymArmTrauma appears exactly once — in this entry. Applying one would mean inventing which effect it becomes and which body part carries it. So the workout is slightly cheaper than the game's, in a warning rather than in silence.

The hideout's wardrobe

Floors, walls, ceilings, shooting-range targets, mannequin poses, and the shooting range's score: three operations, one screen, all three on the "real content gaps" list as unserved, and none of them needed anything imported. emu/decorate is the fifth, sixth and seventh rows to fall to reading the database instead of the sentence about it.

hideout.customisation is imported and populated — an object of two arrays. The 38 globals are 11 floors, 10 walls, 8 ceilings and 9 shooting-range targets, each with an itemId. The 47 slots are 29 poster slots and 18 statuette slots — places to put a thing, not things — and they carry no itemId, which is why applying one is refused rather than guessed at. templates.customization, the same 728-entry table emu/customise already validates clothing against, carries the other half: its node tree has Floor, Wall, Ceiling, ShootingRangeMark and MannequinPose nodes beside the Head/Body/Voice ones, and every one of the 38 globals' itemId resolves under one of the first four.

The profile's key is derived, not written down. Hideout.Customization is Dictionary<String, MongoId> in the reference DTO — a map with no declared keys, so nothing says what to call the floor. The join is offer.itemId -> templates.customization[itemId]._parent -> ._name, and that node name is the table's own word for what the thing is. It is also, in all 38 cases, exactly the offer's own type capitalised — checked on every apply and over the whole real table at load by selfCheckDecorate. That cross-check is not decoration: a wall whose itemId is a ceiling would otherwise write a ceiling into the wall's slot, and the symptom is a hideout that renders wrongly with nothing in any log.

Refused, each naming what it costs the player: an offer id the table does not have; an offer that is one of the 47 slots (nothing to write, and inventing an itemId would put an arbitrary poster on the wall); a condition that is not met, with the shortfall stated — the four kinds this database uses are Block (18 of the 38), Quest (12), Level (3) and HideoutArea (1); a condition kind this server does not know, because a gate nobody can evaluate is still a gate; and a database missing either table.

Mannequin poses are {Poses: {slotId: poseId}}, and each value is checked through emu/customise's entryAllowed and then against the MannequinPose node, of which this database has ten. Each key is the client's word and cannot be checked here: it names a mannequin, and there is no table of mannequins — hideout.customisation.slots has posters and statuettes and no mannequin slot at all. So the key is stored as sent, and a wrong one is a pose recorded under a slot the client never reads. Whether a pose is unlocked is not checked either, for the reason emu/customise gives about clothing: unlocks live in CustomisationUnlocks, filled by BuyCustomisation, and trader/<id>/suits.json is not imported. Five of the ten poses carry AvailableAsDefault: false and are wearable here — more permissive than the game, and a stated gap.

The shooting range's score is {Points: n} into Stats.Eft.OverallCounters.Items as {Key: ["ShootingRangePoints"], Value: n}, and the number is entirely the client's word. There is no shooting range in this process: no target, no bullet, no hit. What can be checked is checked — that the player has area 12 at all, which is the areaType every shootingRangeMark condition gates on, and that the number is not negative. It is stored as sent rather than as a running maximum, because the string ShootingRangePoints appears nowhere else in db.json — no quest condition, no achievement — so its only consumer is the client's own board, and a server showing a higher number than the client just displayed would be inventing a score. The counter key is the well-known client spelling and is (unverified): the reference carries the request DTO and not the key.

Hideout bonuses do apply: syncBonuses in emu/production rebuilds Bonuses from the area table every time an area's level changes, and fuel burns against a derived clock rather than a ticked counter.

The whole hideout used to be free, and instant. applyHideout never read stages.<n>.requirements at all, and HideoutUpgradeComplete checked only the constructing flag while ignoring the completeTime the start had just computed. A profile with 468,989 roubles and none of the prerequisites took area 10 from level 1 to level 3 in four requests with its money unchanged. Both are now the same resolve-verify-then-take rule the rest of this server follows, and the resolver is resolveRequirements in emu/production — the one a craft uses, because a stage and a recipe carry the same requirements shape.

Neither fault was visible on the test fixture, whose areas require nothing and complete instantly; both came out of a run against a real database. The fixture now carries three areas that exist to make them visible here too.

A stage is refused unless it can be paid for in full, and that briefly was not true. Against a real hideout.areas, twenty-five of the twenty-eight areas ask for items at stage 1 — the workbench wants two bolts, two screw nuts and a multitool — and a new profile has a stash, 500,000 roubles and nothing else. Area 10 stayed at level 0, every craft in it was refused "that area is not built yet", and realtest failed four checks on that single cause. The answer to that was a partial take: spend what the stash holds, warn about the rest, grant the level. It is the client being given something it did not pay for, and it has been reverted.

The real finding underneath it is about the economy, and it is precise: in an imported database no trader sells a bolt or a screw nut at any loyalty level, and the flea's generated pool is filled by trader stock long before it reaches the handbook, so no shop in this server has one either. The only route is the one the loot tables describe — every real map spawns both — and tools/realtest.nim now takes it: it walks the area's whole requirement plan, buys what a trader stocks, buys what only the flea has, checks the rest against the locations table, brings it home through /client/match/local/end, and then builds the area under the strict rule and runs the craft. What it cannot get at all it names by template id.

Two more gaps in the same code, both real and both closed:

GapWasIs
a recipe's Resource requirement (1 in the real table)permitted — the craft ran, the output landed, and the filter it should have spent was still fulldrawn out of the area's own slots, verified before anything is taken, and refused when the slots are short
a recipe's TraderLoyalty, Skill and QuestComplete (43 of the last)permitted, because applyProduction resolved requirements without a profile to read them againstthe profile is passed through, so a craft a quest unlocks is locked until the quest is finished

Three more real-data shapes the fixture did not have, each now honoured and each pinned in emutest by a fixture area written to match the real one:

ShapeWasIs
requirements beside stages, switched on by enableAreaRequirementsnever read — the Place of Fame, the Gym and the Cultist Circle could be built without the emergency wall they sit behinda hard gate, and only when the flag is set: twenty-five of the twenty-eight areas carry a list the game does not apply
Skill requirements in a stage (nine of them)silently permittedresolved through the profile's own progress and the game's curve
an area upgraded past its last stagefree and instant, for ever — and each level satisfied a recipe's Area requirementrefused, unless the database describes no such area at all

stages.<n>.constructionTime is a float in every imported area (10800.0, 43207.2) and an int in the fixture; asInt truncates through asFloat, so it reads correctly, and this is recorded because it is the kind of difference that would not be.

The scav case is served, and it too was recorded as blocked on a table that is present: hideout.production.scavRecipes, five recipes on a live dump, has been imported all along. What was missing was the reward pool, because a scav recipe names a count per rarity and no templates at all:

"endProducts": {"Common":    {"min": 0, "max": 0},
                "Rare":      {"min": 1, "max": 1},
                "Superrare": {"min": 3, "max": 5}}

The real server fills that from a rouble price band in its own config (ScavCaseConfig.RewardItemValueRangeRub), which is not in any database. But every item template carries _props.RarityPvE, and its three values are Common, Rare and Superrare — the same three strings, spelled the same way, that endProducts is keyed by. So the pool is a join on the data rather than a band invented here. Three eligibility rules narrow it, each with a reason:

rulehow many it removes, in this orderwhy
it must have a handbook entry priced above zero168the handbook is what this server prices everything by; an item it does not price cannot be sold, so a case full of them is a case the player can do nothing with — and it bounds the walk to 4,288 rows rather than 4,673
it must not be a quest item4 more (144 in total, most of which the price rule already took)a quest item that appears from nowhere is a quest the player can no longer fail properly
Not_exist is not a rarity1,505it is the database saying the item does not drop

which leaves 567 common, 1,287 rare and 757 superrare. The pool is built once per process, on the first case that is started, and a server nobody runs a case on never pays for it.

Four things about the implementation are decisions:

  • The roll happens at start, not at collection, and is stored on the record under the reference's own sptIsScavCase marker. Rolling at collection would let a player who did not like what came out restart the server and collect again.
  • The area gate is made by hand. A scav recipe carries no areaType, so there is no Area requirement for resolveRequirements to find; without the gate a profile with no hideout at all could run scav cases. The area number is 14, and it is read out of the database rather than remembered: the reference carries HideoutAreas as an enum with its values stripped, and the English locale has hideout_area_14_name = "Scav Case" against hideout_area_16_name = "Hall of Fame". The same table confirms GeneratorArea = 4.
  • What lands is struck off the record as it lands. A craft that makes one thing can be refused whole when the stash is full; a case that makes five cannot, because three may fit and two may not and there is no way to put the three back. So the record is rewritten with exactly what is still owed, the player is told how many are waiting, and collecting again after clearing space gives them the rest — once.
  • A rarity that is asked for and has no pool refuses the whole start, before the entry fee is taken. A case that produced nothing because the database had no items of a rarity it named would be a theft with a shrug attached.

What is deliberately not modelled is named rather than left to be found: the real server's reward blacklists, its "at most one money reward per rarity" rule and its ammo handling all live in that same absent config. Without them a case can roll a stack of roubles or a box of ammo where the live game would have re-rolled. That is a difference in the distribution, not in the arithmetic, and it reads to a player as an occasionally dull case rather than a wrong one.

And three production refusals used to say nothing at all. applyProduction reported only the refusal that came out of resolveRequirements; "no such recipe" and "that recipe is already running" set the string and returned it to a caller that dropped it, so the client got err:0 with an empty warnings list for an action that did nothing. Every refusal on both start paths is reported in one place now.

Quests

OperationActionStatus
AcceptQuest, CompleteQuest, HandoverQuest, FailQuestQuest*served, with the conditions evaluated
ActivityPeriods/client/repeatalbeQuests/activityPeriodsserved — now generated; see below
ChangeRepeatableQuestRepeatableQuestChangeserved — added
Statistic (achievements)/client/achievement/statisticserved — now real; see below

Repeatable and daily quests are generated. They were never a missing route; they were a generator with no tables behind it, and the tables are now imported — templates.repeatableQuests out of database/templates/, and configs.quest.repeatableQuests out of SPT's own configs/quest.json, which is the only thing aowl importdb reads from outside the database directory. docs/IMPORTDB.md says why it has to.

The generator is emu/repeatable, and four things about it are worth having on this page.

Nothing is stored that can be derived. A set's quests are a pure function of (profile id, set name, now div resetTime). There is no "today's dailies" record to write, migrate or lose: the same request answers the same quests for the whole day, a restart mid-day changes nothing, and the day after tomorrow's are as computable as today's. The one thing that cannot be derived is a reroll, because it is a choice the player made, so repeat.<profile id> holds — per set — which period it is about, how many times each slot has been rerolled, and how many free changes have been spent. That record is a handful of integers and it lapses when its period does.

A repeatable quest is an ordinary quest whose template is computed. The whole integration is one lookup: questTemplate in emu/quests asks the database first and falls back to the generator. Accepting one, handing items to one, completing one and failing one therefore go through the code that was already here and already tested — the same condition evaluator, the same counters, the same reward payout, the same refusals. The database is asked first so that a generated id can never shadow a real quest.

A daily accepted and not finished before its period ends becomes a quest with no template, which emu/quests already handles: it completes with whatever rewards the missing template does not name, and says so. That is the right answer — the alternative strands the player on yesterday's daily forever.

And the profile is bounded. Three dailies a day kept forever is a document that grows for the life of a character, and this server reads and writes the whole document on every request — the mailbox is bounded for exactly that reason and the measurement is in EMULATOR.md. pruneExpiredRepeatables runs at raid end and drops only entries that are all three of: marked sptRepeatable when this server accepted them, finished (Success or Fail), and no longer offered. The last of those is what stops a pruned quest being re-accepted for its reward again.

Where the two tables do not decide something, the module says so in a comment rather than inventing a rule. There are five such places and they are all marked:

not decided by the datawhat is done instead
what rewardScaling.items (a count of item rewards) is worth, and how much of the rouble budget it replacesthe budget is paid in roubles, whole. Paying n items on top of the full rouble figure would be a daily worth more than the config says
gpCoinsnot paid; it names a currency this server has no other dealing with
what rewardSpread is a fraction ofread as a fraction either way — 0.25 pays between 75% and 125%. (unverified); it is the reading that makes 0 mean "exactly the figure"
how rewardScaling.levels interpolates between its pointsit steps: a level 17 player gets the level 10 row
specificExits.chance — an exit point, which this server is never told aboutreused as the chance of naming a map, which is the part of the same restriction this server can check. (unverified)

Two things are read and deliberately not applied, and the reason is this server's own evaluator rather than the data:

  • Pickup quests are not generated. Their band is ItemTypeToFetchWithMaxCount, a list of handbook categories with counts, and turning a category into a pool of templates means walking the handbook tree for every template in the game on every request. Only the scav set offers the type, and its other three are generated.
  • The weaponRequirementChance, weaponCategoryRequirementChance and distProb qualifiers on an Elimination band are read and not written into the quest. questcond.killCredit refuses to credit a kill carrying a qualifier it cannot check — a weapon, a distance, a time of day — so a quest built with one would have no route to completion except the client's own counter agreeing with it. The two qualifiers the evaluator can check, bodyPart and Location, are applied.

Achievements are awarded. The reference makes this cheap: Achievement.Conditions is the same five condition groups a Quest has, and Achievement.Rewards is the same Reward list — so the evaluator is emu/questcond unchanged and the payout is the one the quest payout itself now goes through. The profile keeps them at Achievements, the reference's Dictionary<MongoId, Int64> of id against the moment it was obtained, which is also the "already awarded?" test and a complete one.

Two rules decide what may not be awarded, and both refuse:

  • an achievement with a condition the evaluator has no arm for. groupMet counts those separately and a non-zero count refuses, which is the same rule killCredit applies to a qualifier it cannot check.
  • an achievement whose finish group is empty. Every condition of none is trivially met, which would award the whole table on a player's first raid.

StatisticCompletedAchievementsResponse.Elements, a {id: count} map — is counted over the profiles the store actually holds. The reference gives the shape and not the meaning of the number; the real server's is a population statistic, and on a server whose population is its own store that is exactly countable rather than a thing to fake.

It runs after a raid and after a quest is handed in — the two moments the profile changes in a way an achievement condition reads — and deliberately not on every item event, because the table is a few hundred entries and a stash is dragged in thousands of times a session.

Prestige is listed and never obtained. ObtainPrestige is a profile wipe with carry-over rules, and getting it wrong costs a player their character.

Mail and messaging

OperationURLStatus
GetMailDialogList/client/mail/dialog/listserved
GetMailDialogView/client/mail/dialog/viewserved
GetMailDialogInfo/client/mail/dialog/infoserved, empty
GetAllAttachments/client/mail/dialog/getAllAttachmentsserved
SetRead, PinDialog, UnpinDialog, RemoveDialog/client/mail/dialog/*served
GetFriendList/client/friend/listserved — was mis-shaped, [] where GetFriendListDataResponse is {Friends, Ignore, InIgnoreList}
ListInbox / ListOutbox/client/friend/request/list/*served, empty
CreateNotifierChannel/client/notifier/channel/createserved
GetNotifier/client/notifier/getwebsocket*served — a real websocket, with the poll kept as the fallback; see below
SendMessage, ClearMail, AddUserToMail, CreateGroupMail, ChangeMailGroupOwner, RemoveUserFromMailnot served
The 8 friend-request operationsnot served

The notifier is a websocket now, and the poll is the fallback. This entry used to read "polled, not a websocket", and it was honest about why: the backend served on a fixed pool of accept threads, a held connection occupied one for the whole wait, and four players idling in the menu would have taken the pool. It also said the fix was a change in backend/ rather than in the mod, and that is what happened — the server is a poller with a pool of request workers, a connection nobody is answering costs a socket and its buffer, and the bound is 1024 connections and 64 MiB rather than sixteen threads.

So a request to /client/notifier/getwebsocket/<session> carrying Upgrade: websocket is now answered 101 and held, and the mod pushes down it with the revision-5 notifyPush. The frame is the same JSON event the poll handed back, so nothing about what the client reads changed — only when it arrives, and that it arrives at all without being asked for.

The poll is kept and is not a legacy. notifyPush answers ErrNotFound for a session with no socket open — a client mid-login, one whose connection just dropped, and every tool in this repository that drives the server over wire.nim — and emu/notify.deliver queues whatever it could not push, so the next poll carries it. A client that never upgrades behaves exactly as it did before.

What moved in the mod is smaller than it sounds: the sweep no longer posts a summary note, because emu/mail.deliver now notifies at the point a message is written. A quest reward, an insurance return and a sold flea offer all arrive there, so the badge appears when the message does rather than up to a minute later on the next sweep.

tools/wstest.nim is the gate — 62 checks, including the handshake accept computed independently of the server, a notification provoked through the game's own routes and asserted frame by frame, the refusals (a bad key, no Connection: Upgrade, a version that is not 13, an unmasked frame, a frame claiming an absurd length, a reserved bit, an unknown opcode, an orphan continuation), a client that vanishes without closing, and a hundred idle websockets held open while ordinary requests are timed behind them.

What was closed in this pass, and what it cost

Three things: two operations that were recorded as blocked on missing data and were not, and one class of silent refusal behind a surface that was already served.

The checks for the first two are not in emutest, and that is a decision rather than an omission. Both are only reachable through a route on a database that carries the table — globals.config.Health for one, _props.RarityPvE and a priced handbook for the other — and tests/fixtures/emu-full.json carries neither. A check written at the wire would be a check that never ran. So the arithmetic is pinned by self-checks that run at load, through emu/selfchecks, where a failure refuses the load: health.selfCheckHealth prices ten treatments against a literal table and production.selfCheckScavCase rolls six cases against literal pools — 21 assertions between them. That gate was verified by breaking one expectation in each and confirming the server would not serve — error self-check: health: 40 points at 30 should be 1200 and error self-check: scavcase: a zeroed rarity must not need a pool, and emutest then failing at "the backend never answered /client/game/config".

ClosedPinned by
healing at a trader (RestoreHealth)40 hit points at 30 is 1200 and 1200 at a coefficient of 135 is 1620; a part-rouble price rounds up rather than to nothing; a coefficient of zero reads as full price rather than as free; an effect the table gives no RemovePrice is refused by name; an absent price table refuses rather than heals free; ten points plus a fracture plus a light bleed is 1700, which is the check that effects are added to the points rather than charged instead of them
the scav case (ScavCaseProductionStart)a recipe naming 1 rare and 3–5 superrare rolls between 4 and 6 items and exactly one of them is rare; the same seed rolls the same case, byte for byte, which is what makes a roll something a bug report can name; a rarity zeroed to max: 0 does not consult its pool, which is the pair that separates "the band is read" from "the pool is drawn from"; a rarity that is asked for and has an empty pool refuses by name and refuses before the entry fee is taken; a max below min reads as min rather than as a negative count
three production refusals that said nothingthere is no arithmetic to pin here — the evidence is the refusal text now reaching the client, which the driver below asserts for all three

And both operations were driven end to end against the real 39 MiB imported database, because that is the only place the tables exist. What that run asserts, in the same style as the suites above:

healing, 15 checksa head at 10 of 35 with a fracture on the left leg and 60 of 100 energy is treated for exactly 1750 — 25 points at 30 plus the fracture's 1000, with energy priced at the table's 0 — the fracture is gone, the head is full and Therapist's salesSum is the same 1750; a treatment with nothing to treat is refused by name and takes no money; an untreatable effect is refused by name and the hit points bundled with it are not treated either; an unknown trader is refused; and Prapor, whose heal_price_coef is 0 on every row, charges 150 for five points rather than nothing
the scav case, 33 checksan unbuilt scav case is refused by name and costs nothing; built, a 2,500-rouble recipe takes exactly 2,500 and writes a record marked sptIsScavCase with its rewards already rolled and the recipe's own 2,500-second clock; every rolled template is a real template, of a rarity endProducts names, not a quest item, and priced above zero by the handbook; a second start of a running case is refused and does not charge twice; collecting before it is finished is refused; collecting after it is gives exactly the rolled templates, each in a real cell of the stash, and clears the record; collecting again is refused; and an ordinary craft recipe id is refused with "no such scav case" rather than answered out of the craft table

What was closed in the pass before that

Four things, three of which are holes behind a surface that was already served, and one importer change without which the first of them could not be built honestly. Each is pinned by checks in tools/emutest.nim that assert arithmetic and state rather than the presence of a substring:

ClosedPinned by
the importer brings across the two repeatable-quest tablesverified against a real install: 39.40 MiB, 4 quest types and 3 sets imported, four new self-check rows all at zero dangling references, and the refusal to write anywhere under --from still refuses
repeatable and daily queststhe config's two quests are generated, each asking for exactly the 3 extracts its level band names and paying exactly the band's 500 experience, 3000 roubles and 0.01 standing at a spread of zero; asking twice gives the same two rather than two more; one is accepted with its conditions intact and refused completion at "CounterCreator 0 of 3"; the first change of the period is free and takes no money, the next costs exactly the skeleton's 7000 and not the request's anything; a change alters exactly the quest it named and leaves the other alone; an accepted quest cannot be changed and neither can an id the profile does not have
the root-container hole in Move and Swapmoving the stash into the equipment root is refused and the stash still has no parent; so is moving the equipment root into the stash, in the other direction, so the check is not one id being special-cased; a swap that would do either is refused and neither container moved — and the money that was the swap's other half did not move either, which is the check that a refused action applied no half of itself
bot loottwo bots carry exactly the 4 items their pool and count table say between them, each in its own cell of the rig's 2×2 grid and each naming the grid it is in; a backpack whose grid is 1×1 and whose pool holds a 2×1 item gets none rather than an overlapping location; pockets with room in them and a count of zero get nothing, which is the pair that separates "the pool is drawn from" from "a zero count is a refusal"
TraderRepair.ExcludedCategorya trader excluding a category two levels above the one the plate's handbook entry is filed under refuses it, which is the check that the tree is walked rather than the leaf matched; the same trader does not refuse an item filed in another branch, which is the check that the exclusion is narrow rather than a trader who repairs nothing; a trader who excludes nothing repairs the same plate, for exactly the 20 points it was missing and exactly 2000 roubles

And the pass before that

Nine operations and four systems, each with checks in tools/emutest.nim that assert arithmetic and state rather than the presence of a substring:

ClosedPinned by
repair at a trader40 points of damage asked for as 9999 costs exactly 2000 roubles, restores exactly what the item can now hold, and takes exactly one point off MaxDurability; an undamaged item is refused and no money moves; an item the database gives no durability is refused; the same item named twice in one body is refused and is not repaired once either
repair with a kitclass 4 armour costs exactly two resource units a point, so 30 points leaves a 200-unit kit at 140; a repair with a 20-unit kit restores exactly the ten points it can pay for and no more; a kit spent to nothing is gone rather than left at zero; a kit named twice is refused with the kit untouched; an item cannot repair itself
the client drives the weara rifle handed back from a raid at 60 of 100 is still 60 of 100 on the server — the check that says there is no second, server-side degradation on top
ApplyInventoryChangesthe layout is applied and the markers on the item survive it; an entry claiming a different stack size is refused and the money is exactly what it was; an entry that would make the stash its own descendant is refused, and so is one that reparents a container the profile is built on -- which closes no loop and does the same damage -- and the stash still has no parent after either; a batch may not delete and may not invent an item
notesadded, edited in place rather than duplicated, deleted; an index the list does not have is refused rather than clamped, and the note it named never appears
map markerson the item and not the profile; a second marker in one cell is refused; an edit moves the marker rather than adding one; the map's own MaxMarkersCount is the limit and the fourth is refused; a marker on an item the player does not own is refused
achievements awardedthe one whose condition is met and checkable is awarded, its experience paid exactly once, and a second raid does not award it again; the one with a condition this server cannot evaluate is not awarded; neither is the one with an empty condition group; the statistic counts one
Fence / scav karmathe scav section's survived-and-died pair moves the standing by exactly one extract's worth, which is the check that a death costs nothing; a third extract moves it by exactly the configured rate again
bot loadoutsa generated bot wears the loadout table's hat and carries its weapon, with the mods the chance table gives 100 and none of the ones it gives 0; the magazine holds exactly the magazine's own capacity of the ammo keyed to the weapon's calibre; a slot at chance 0 is empty
bot limit and difficultythe limit is the map's BotMaxPvE, and a map the database does not know gets the configured default; the difficulty is the role's own block, and an unknown one falls back to bots.core
weatherthe database's sky, with a timestamp that is now rather than whatever the table said
hideout upgradesan upgrade needing an unreached loyalty level is refused, and so is one needing an unbuilt area; an affordable one takes exactly the stage's 1200 roubles; it cannot be completed before its construction time and the area is still level 0; a second start is refused rather than charging twice; a stage asking for materials the stash has none of is refused naming them, moves no money and creates no area; a stage asking for an item the stash does hold is accepted and the item is gone afterwards
hideout craftsa craft needing a resource the area has none of is refused, one with the filter in place starts and leaves the filter at exactly 100 - 66, and a second one is refused on what is left; a craft a quest unlocks is refused naming the quest until the quest is a success

And the one before that

Nine gaps, each with checks in tools/emutest.nim that assert the arithmetic rather than the presence of a substring:

ClosedPinned by
Insure, and the return that follows it a day laterpremium is exactly 110 of a 1100-rouble kit; never charged twice; refused for an item the player does not own; the kit lost in a raid comes back after the mid-run restart, once
trader loyaltysalesSum is exactly the run's own turnover; the level is derived from all three requirements, so LL3 with the sales sum met and the standing not is refused; a gated offer is refused before the barter is taken
out-of-raid healing and eatinga 9999-point heal restores exactly the 25 missing and takes exactly 25 off the kit's 400; ten of sixty units of a bottle are worth ten hydration; an empty bottle is gone rather than left at zero
saved buildsthree lists when empty; re-saving under one id replaces rather than adds; deleting twice is refused; the build survives the restart and a raid that handed the profile back
the wishlistfiled under its category, re-filed in place rather than duplicated, refused for something not on it
favourites and the hotkey barrefused for an item the player does not own; rebinding moves rather than copies
pin staterefused for a state outside the reference's three
cancelling a craftthe record clears and the materials do not come back — an exact difference, because "the record is gone" passes against a server that refunded
five mis-shaped menu routesthe three friend lists, elements on achievements and on prestige, suites on customisation storage, three lists on builds

Two ways to lose an account, closed

Both are the same shape, and the shape is worth learning because it is not about profiles: two different situations answering the same value, where the recovery for one of them is destructive to the other.

"There is no such profile" and "the profile could not be read" were the same answer. storeRead returned false for both, the host answered ErrNotFound for both, load gave ok: false for both — and the recovery for the first is to create one. Applied to the second, that writes a new character over a real account: a sharing violation, a disk hiccup, a file another process has open, and a career becomes a level 1 profile with 500,000 roubles, with no error anywhere. The store now carries Stored.missing and emu/profile.readProfile refuses loudly on a read that failed rather than reporting absence.

And the same trap sat under five other keys. The mailbox, the insurance queue, the saved builds, the player's flea offers and the scav record are all read-then-rewrite-whole: an unreadable key answered as "empty" is a key the very next write replaces with an empty one. For the mailbox and the flea that is items, because a listed offer's items are out of the profile and the mailbox is the only place an unredeemed reward exists. emu/store.readKey is the one place the distinction is made now, and the rule is asymmetric on purpose: a read-only path takes the empty answer and logs, and a path about to write refuses. Reading wrong is a screen; writing wrong is an account.

Ids that repeated across a restart, closed

seedIds(nowMs()), and the ABI is explicit that now_ms is monotonic milliseconds since host start — so the seed was a two-digit number and the counter restarted at 1 on every boot. Server A created a profile 00000000003f000000000001 with stash …0002 and equipment …0003; server B, started against the same store, issued those same three ids to three items a player bought. Inventory.items then held ten items with seven distinct ids, the stash root was also a rifle, and the client — which draws the stash by walking parentId down from the root — could not draw the tree at all.

Neither existing test could see it: emutest restarts but issues nothing afterwards, and soak issues thousands and never restarts.

The fix is persistence, not a better seed. A run number is read from the store at load, advanced by one and written back before a single id is handed out — before, not at shutdown, because a server that is killed rather than stopped must still not reuse its number. A load that cannot establish it refuses to start the mod, which is the point: a server that cannot guarantee a fresh id is a server that corrupts the next profile it touches.

Three ways to destroy a profile, closed

Found by tools/fuzzwire, not by reading. All three answered err:0 with no warning, which is why none of them showed up in normal play until they did.

An item could become its own ancestor. {"Action":"Move","item":X,"to": {"id":X}} wrote parentId: X onto X. Worse, the same request naming the stash and something the stash holds made the inventory's root its own descendant — and the client draws the stash by walking parentId down from the root, so what it drew afterwards was nothing, and nothing could undo it: the drag that would fix it has to reach an item the client can no longer see. wouldCycle in emu/inventory walks up from the destination and refuses if the item being moved is anywhere on that path. Swap is two moves in one action and had the same hole; it gets the same check on both destinations.

The stash could be listed on the flea. Listing an item takes it and everything under it out of the profile, and everything under the stash is everything. The profile came back with Inventory.stash naming an item that was no longer in items. emu/market now refuses any item that is not in the stash: walk up parentId and see where you land. The five containers a profile is built on are exactly the items with no parent, so the same rule catches all of them without the market needing to know their ids — and it catches equipped gear, which hangs off the equipment root and is not for sale either.

Concurrent writes were silently discarded. Six racing buy_from_trader on one profile all answered err:0; three rifles arrived and three purchases went into the bin, because each request had loaded the document before any of them saved it. Nothing duplicated and no money went missing — the arithmetic was self-consistent every time — and the client was still told three things happened that did not. Every save now stamps a per-profile counter and saveIfUnchanged refuses when the counter has moved since the request read the document, so the client gets a failure it can retry.

A counter and not a re-read: reading the stored profile immediately before writing it made the store's atomic replace fail with a sharing violation, and a fix for a silent discard cannot be a new way to lose the write.

That last one is narrowed, not closed, and the rest of the fix is not ours. Two writers can still pass the comparison and both write. Closing it properly means the host serialising item-event handling per profile — one request at a time for a given profile id — which is a change in backend/. A mod cannot do it: there is no lock in the mod API, and there should not be one, because a mod holding a lock across a request is a mod that can stop the server.

One related decision, recorded because it looks like a hole and is not: any session may select any profile. There is no credential in this protocol to check one against — the client authenticates at the launcher and then talks to a backend it reaches over loopback with a session id it chose itself. If this ever listens on anything but loopback, the check belongs at the socket in backend/, not here: an ownership test built out of the session id is a test the caller supplies both sides of.

The numbers

emutest went from 150 checks to 243 to 334 to 372 to 383 to 404 — the last eleven of them pin what the flea market's offer list is made of, which nothing had ever asked. realtest, against a 39 MiB imported database, went from 90 checks with one skipped to 94 with none: the skip was "buying a stage material on the flea", and it was skipped because there was nothing on the flea to buy.

soak is green — 915 invariants over its default 24 cycles (864 before an audit found its whole store-leak half summing over an empty key list); the 7,200 figure this file used to quote is a 200-cycle run of the same suite. fuzzwire is 152 checks green; its mods/tarkov failures are zero and what it still reports is in backend/ — HTTP framing, Content-Length limits, zlib refusals and JSON escaping in a 404 body. The two it gained are its own probe's self-checks: it can now tell serving, erroring, garbled, silent, closed, refused and gone apart, and reads the exit code before closing the handle — so a backend killed by something outside the test reads as an ordinary exit rather than as the last request having crashed it. It also probes after every case rather than once per phase, because naming the wrong request is worse than naming none: one misattributed death sent an investigation after Content-Length: 9000000000000000000 for hours, and that value was handled correctly the whole time.

When the gym and the wardrobe landed they moved none of those four numbers, and the reason is worth keeping: both were unreachable on tests/fixtures/emu-full.json, which carried neither hideout.qte nor templates.customization, so no wire suite could see them. What went in instead was 41 assertions that run at load in emu/selfchecks — the whole of the workout's arithmetic against a literal QTE entry, and the whole of the customisation validator against a literal table — plus, uniquely, the one data-conditional check in that module: when the loaded database has a customization table, the ids a new profile is created with are run through the same validator a CustomizationSet goes through, and a failure refuses the load. That check is what caught the scrambled defaults. Both routes were also driven by hand against the 39 MiB database: the refusals quoted in The gym and The wardrobe above are its actual output.

The fixture has since been extended and the wire checks exist. It now carries hideout.qte (area 23 with a four-event entry and the real results block) and a small templates.customization, and tools/emutest.nim has sections for Healing at a trader, The scav case, The gym and Customisation. The settled numbers, all four suites re-run and green:

suitewasnow
emutest (fixture)404541
realtest (39 MiB real database)94144, one skipped
soak864915
fuzzwire149152

All four re-run on 2026-08-19 against a scratch stage and confirmed at those numbers; emutest's own last line is "the emulator answered all 541 checks". Its sections now run: Boot · Creating a profile · Selecting and starting · Static tables · Traders · Inventory · Nothing may contain itself · Trading · Trader loyalty · The flea market · Quests and the hideout · Production · Redeeming a reward · The floor of a raid · A raid · Skills · The scav · Bots · Mail and insurance · Buying insurance · Healing and eating · Healing at a trader · Repair and durability · Notes and map markers · The client's own batch · Achievements · Fence and scav karma · What a hideout upgrade costs · What a bot is wearing · The dailies · The sky · The menu's own state · The scav case · The gym · Customisation · The hideout's wardrobe · Across a restart.

Two of those numbers moved again after the wardrobe and the hideout decorations got fixture support, and the second pass over the fixture is worth reading for what it found rather than for the count. One check was matching the fixture's own _comment text — this route returns the database's members verbatim, prose included — so it stayed green with the array it checks emptied to []. Another asserted on up.ok, which is transport-level, while a refused hideout upgrade arrives as a warnings entry inside err:0; it passed with the area deleted from the fixture entirely.

The /aowlspt/tarkov/selfcheck assertion also had to move to be reachable at all: on a self-check failure every /client/ route 404s, so a run that waits for the game first dies at "the backend never answered" before reaching the check that would have named the cause. Both tools poll the self-check route first now, so a bad build reads as its own red line and then a dead backend, rather than only the second.

realtest's one skip is not a check declining to run for want of a subject; it names a fact about the game: "a real scav case rolls its rewards at the moment it starts: level 1 of area 14 takes 288000 seconds to build". Eighty hours of hideout construction is not something a test run reaches, and pretending otherwise would mean a fixture area that is no longer the real one.

Nothing added this pass is on the path a play cycle takes either. The scav case's reward pool is one walk of the handbook, built on the first case a server ever starts and never again; a treatment reads four values out of the loaded globals; and the market's shares are still worked out once per rebuild, twelve hours apart.

All the numbers above are re-derived, not remembered, and the four commands at the top of this file are the derivation. The table below is written by tools/coverage.nim, which computes all four in nimony by reading the files rather than by shelling out to awk and grep; the commands are kept in the text because they are how a person checks the tool:

commandcount
the callback sweep over reference/spt-4.1-surface.txt226 methods
the ItemEventActions sweep over the same file55 fields
grep -oE 'serve(Prefix)?\("[^"]+"' mods/tarkov/tarkov.nim132 routes
grep -rn 'of "' mods/tarkov/emu/*.nim156 arms

The one new route is /client/hideout/customization/offer/list, which is GetHideoutCustomisation moving out of the empty bucket.

The arm count is not an operation count and never has been. The eleven new arms are 7 in emu/decorate, 3 in emu/dialogue and 1 in emu/market. Only four of the eleven are client actions: HideoutCustomizationApplyCommand, HideoutCustomizationSetMannequinPose and HideoutRecordShootingRangePoints, which emu/decorate does dispatch through a case, and RagFairRenewOffer in emu/market. The other four in decorate are condition kinds and node names, and dialogue's three are placeholder and situation names, which are not operations at all. The previous pass's actions, HideoutQuickTimeEvent and CustomizationSet, are matched by name in gymAction and customiseAction rather than by a case arm, so this grep has never seen them. The caveat is repeated rather than letting the number be read as eleven new operations.

One number, printed one way

Floats used to go into the profile through $, which prints the shortest text that round-trips the binary value — so Fence standing after two runs worth 0.01 each was stored as 0.020000000000000004. Valid JSON, correct arithmetic, and wrong to store twice over: the profile is read and written whole on every request, so a value that gains digits every time it is added to is a document that grows and a request that slows, forever, with nothing announcing it; and a value nobody can quote exactly is a value no test, bug report or support answer can pin.

emu/numbers.numText is the one place this server prints a float. It rounds to six decimal places and drops a fractional part that is not needed, which makes it fixed point in micro-units rather than a prettier print: the stored decimal is the accumulator, so the next addition starts from a clean number and the error cannot compound. The wire stays a number, because the reference declares these Nullable<Double> and a client handed a string is a far worse bug than an ugly decimal. Everything that accumulates goes through it — trader standing, skill progress, the flea rating — and the cost is named rather than hidden: a gain smaller than 5×10⁻⁷ rounds to nothing, four orders of magnitude below any rate this server uses.

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.