Quest events — how Outward tracks story state, and how a mod reads or extends it¶
Outward gates every piece of story progression on quest events: string-UID flags held by
QuestEventManager, each with a stack count and two game-hour timestamps. The journal, the
destroyed-Cierzo object swaps, faction membership, trainer unlocks and the fast-travel table all
read them. This page is the durable write-up of a decompile spike (2026-09-07) so nobody has to
repeat it: architecture, persistence, multiplayer, the query API, the Vendavel flag map, the flags
that only look relevant, and the house ruling on tracking a mod's own quests.
At a glance
- Runtime store:
QuestEventManager(singletonMonoBehaviour, static dictionary keyed by uid) - Definitions:
QuestEventDictionary— 1,971 events in 80 sections, loaded from aTextAsset - Persisted in: the world save (
World.*in the save slot), not the character save - Replicated: yes — the master's store is authoritative, guests are wiped and replayed on join
- Tooling: StoryKit verbs on
BepInEx/config/StoryKit_cmd.txt; offline cataloguedata/registries/quest-events.json; aflag:condition leaf in DangerousRoads
Architecture¶
| Type | Role |
|---|---|
QuestEventSignature |
The authored definition: EventUID (22-char base64 GUID string), EventName, Description, Savable, IsStackable, IsTimedEvent, IsEphemeral, DLCId. Names are plain strings, no localisation. |
QuestEventFamily |
A section — a named list of signatures. Purely an authoring grouping; not part of the key. |
QuestEventDictionary |
Static flat Dictionary<uid, signature> loaded from Resources/_Items/_EditorData/QuestEvents by Load(). RegisterEvent(sig) is public static (the mod extension point); a duplicate uid logs an error and drops the second registration. Load() calls Reset() first — every mod registration is wiped on reload. |
QuestEventData |
The live/saved instance: m_stackCount (default 1), m_activationTime, m_lastUpdateTime (game hours). SaveIdentifier is the uid. CreateEventData(uid) returns null when the dictionary has no signature. |
QuestEventManager |
Store + query API + Photon RPCs. Static m_questEvents survives the MonoBehaviour; OnDestroy clears it. |
Quest items (Quest : Item, with a QuestProgress NodeCanvas graph) sit on top: the Vendavel
graph is 86 Condition_QuestEventOccured + 14 SendQuestEvent + 4 CompleteQuest nodes. The
journal is presentation; the events are the substrate. Graph blackboard booleans are graph-local
and cannot be queried from a mod.
Query API (instance methods on QuestEventManager.Instance)¶
| Signature | Semantics |
|---|---|
bool HasQuestEvent(string uid) |
GetEventCurrentStack(uid) > 0 |
int GetEventCurrentStack(string uid) |
-1 if absent, else the stack — and a stored stack 0 is a real row the game reads as not occurred. Test presence with > 0, never != -1. |
QuestEventData GetQuestEvent(string uid) |
null if absent |
bool CheckEventExpire(string uid, int gameHoursAllowed) |
timed events only |
int GetEventRemainingTime(string uid, int gameHoursAllowed) |
game hours left, clamped ≥ 0; -1 if absent or never activated |
float GetEventActiveTimeDelta(string uid) |
age in game hours; -1 if absent or not a timed event (ActivationTime is only ever set when the signature says IsTimedEvent) |
bool SyncedEventOnce |
the join-sync has completed (guests) |
Mutation: AddEvent(uid, stack=1, sendEvent=true) (idempotent on a non-stackable event — refreshes
time, re-notifies), SetQuestEventStack, RemoveEvent (deletes the row), ReduceEventStack,
ResetEventTimer, ClearEvents. Listeners: RegisterOnQEAddedListener(uid, l) /
RegisterOnQERemovedListener(uid, l) for push instead of polling.
Clock: all times are game hours on EnvironmentConditions.GameTimeF; one in-game day = 24.
Persistence¶
- Written by
WorldSave.QuestEventList←QuestEventManager.ToSaveData, which emits only rows whose signature saysSavable. A row whose signature is missing reports not-savable and is silently dropped — so a save self-cleans when a mod that minted events is removed. - Read by
WorldSave.ApplyData(firstLoad)→LoadEventList, first load only; rows whose signature no longer resolves are skipped;SyncedEventOnceis set at the end. - Serialized form:
"<stack>"or"<stack>;<activationTime>;<lastUpdateTime>"for timed events.ParseDataonly overwrites the stack when the parsed value is > 1 and the default is 1, so a saved stack of 0 comes back as stack 1 = occurred — a dormant row flips ON across a save/load, and across the join-sync wire (same parser). To clear a flag useRemoveEvent, neverSetQuestEventStack(uid, 0). - Scope: per world save slot, shared by every character in it. Not per-player.
Multiplayer¶
- A guest's
AddEvent/SetQuestEventStackis an RPC request to the master, which applies it and broadcasts.RemoveEvent/ReduceEventStackbroadcast directly from the caller. - Join: guest
RequestEvents()→ master serializes the whole store → guest clears its own store and replays. The host's world save is the room's truth; a local read is therefore the room's answer onceSyncedEventOnceis true. - The wedge (NK-F3):
SendQuestEventsis one-shot. If a uid arrives that the guest has no signature for, vanilla NREs, aborts beforeSyncedEventOnce, and the level loader waits forever — the guest is stuck on "positioning players". StoryKit ships a guard ([QESYNC-GUARD]prefix skip, finalizer, send audit) — but only a guest that runs StoryKit is guarded. Any mod-minted uid must be registered on every peer.
Reading a vanilla flag from a mod (the safe, free case)¶
Reading never soft-locks anything. Three rungs exist today:
- Raw:
QuestEventManager.Instance.HasQuestEvent(uid)(null-guard the instance). - StoryKit facade (
StoryKit.QuestFlags, from StoryKit 0.1.16):Has("Vendavel_Succeeded"),Stack,AgeHours,RemainingHours(name, allowed),IsSynced— names resolve through a generated table and then the live dictionary. Gate-level:KnownGates.Vendavel→Resolve(has)returnsUnresolved | Saved | Razed;queststateprints it. - DangerousRoads card grammar (
BepInEx/config/cobalt.dangerousroads.cfg):[Events] ## when a card may be drawn — grammar in the DangerousRoads page AlphaWhen = flag:Vendavel_Succeeded PatrolWhen = !flag:Vendavel_Succeeded & !flag:Vendavel_Failure & !flag:Vendavel_CierzoDestroyedflag:accepts a uid or (from DR 0.1.17) a name;flagage:NAME>H/flagstack:NAME>=Nfollow.
Never type a raw uid into source: names are the contract; data/quest-flags.json +
scripts/quest-flags.py gen produce the id table, and forge db lookup <name> resolves one offline.
The Vendavel flag map (section Neutral_Vendavel, all savable)¶
Quest item Neutral_Vendavel_Quest (a main quest) is given by Burac's variant dialogue tree.
Outcome flags, with who sets them (from the exported quest graph + every scene/behaviour tree):
| Name | Set by | Notes |
|---|---|---|
Vendavel_QuestTimer |
the five faction prequest dialogues (Blue Chamber, Holy Mission, Heroic Kingdom, Sorobor) — not Burac | timed; removed by the quest on any resolution. No timer row = never committed to a faction. |
Vendavel_Succeeded |
quest graph when the third warlord resolves (*Resolved ×3 and not destroyed), same node clears the timer; also the Chersonese region tree as a quest-independent fallback (all three warlords dead, never took the quest) |
"the fortress went quiet" — fires before the quest completes |
Vendavel_BuracThank |
Burac's dialogue, with the reward | the later, skippable "thanked and paid" beat; the quest waits on it before CompleteQuest(true) |
Vendavel_Failure |
only the 12 non-Chersonese region/city trees: CheckQuestEventExpiry(QuestTimer, 960) && !Failure && !Succeeded |
the provisional verdict ("GotOnOtherRegion"); cannot be set while standing in Chersonese |
Vendavel_CierzoDestroyed |
Chersonese region tree, Cierzo village tree, the fast-travel table (MerchantFastTravel), and the quest's failure branch — whichever the player hits first after Failure |
timed; the ruin's 72-hour "aftermath" swap keys off its age. Use alone when a card needs the ruin physically present. |
Vendavel_AutoBurac |
Burac's tree, with GiveQuest |
"the player was actually given the quest" |
Vendavel_{Crock,Balira,Rospa}Resolved |
fortress tree / warlord dialogues (death or pacification) | per-warlord progress |
Vendavel_HOSTILE, Vendavel_Hostility (stackable, to 5) |
fortress hostility ladder | |
General_BuracGaveKey (section Neutral_General) |
Burac, on success | town key handed over |
Timer facts: ExpiryTime = 960 game hours = 40 in-game days, uniform across all 12 trees
(read from serialized data, not observed live — prefer GetEventRemainingTime at runtime). The
countdown does not pause in Chersonese; the verdict is simply never pronounced there. Failure →
destruction is a second step, so there is an arbitrarily long window with Failure set and
CierzoDestroyed not — which is why the "razed" state must be Failure | CierzoDestroyed.
Succeeded and Failure never coexist in the engine (every setter tests both), but the F4 cheat
window and qeventtoggle can set both — evaluate razed before saved.
Three states: Saved = Vendavel_Succeeded; Razed = Vendavel_Failure | Vendavel_CierzoDestroyed;
Unresolved = neither. The Quest item's ProgressState is a genuine tri-state too, but
IsQuestCompleted returns true for Success and Failure, and the fallback path grants Succeeded
without a journal entry — gate on events, not the journal.
Dead and misleading flags — do not gate on these¶
| Flag | Why not |
|---|---|
Vendavel_CierzoRazed (zChersoneseVendavel) |
referenced only in the registry — legacy, never set. The single biggest trap: its description reads like the failure flag. |
Vendavel_Player_Enslaved |
same — registry only |
CalltoAdventureFail |
tested by one house tree, never set anywhere; the live failures are CallToAdventure_Expired / _LostHouse / _QuestEndedA/B |
BuracDone (zCierzo) |
live but it is the tutorial "Burac let you out of Cierzo" gate — unrelated to Vendavel's outcome |
("Dead" is a strong inference from a full asset export, not proof; the recommendation holds either way.)
Tooling (StoryKit, channel BepInEx/config/StoryKit_cmd.txt)¶
| Verb | Does |
|---|---|
qeventcatalog [filter] |
enumerate the authored dictionary with live stack + ACTIVE/DORMANT |
qeventdb |
dump the catalogue to BepInEx/config/StoryKit_registries/quest-events.json (copy to data/registries/) |
qeventtoggle <uid\|name> |
present → RemoveEvent, absent → AddEvent; resolves names (exact, then unique substring) |
qeventdump [filter] / qeventage [uid] / qeventlisten [uid] |
the live store, timing, a push listener |
qeventadd <uid> [n] / qeventset / qeventdel |
stack-precise writes; qeventadd auto-registers an unknown uid non-savable (session-only) |
queststate [gate] · qeventpreset <gate>=<state> [confirm] |
gate-level read; flip a known gate's flags to a state (warns; needs confirm) |
ForgeKit cheatmenu quests |
the vanilla F4 DT_QuestEventCheats window |
Drive them with ./scripts/game-cmd.sh --mod storykit <verb…>; forge storykit qeventtoggle <TAB>
completes event names. Writing a vanilla plot flag can soft-lock a questline (scripts assume
flags arrive in order; an expired parallel timer will not un-expire) — back the world save up first.
Ruling: tracking a mod's own quests¶
Use vanilla quest events (custom uids) for quest STATE — stages, flags, counters, deadlines. Use a
homemade save file only for structured payloads or per-character scope. Save, join-sync, timers,
listeners and the verbs above come free and correct; the store is self-cleaning if the mod is
removed; and vanilla glue (GC_HasQuestEvent, DialogueCondition_HasQuestEventOccured,
ActivateActivatorOnQuestEvent, Unlock_QuestEventOccured) can react to our flag. Conditions:
- Prefix every uid
<mod>.<feature>.<name>(vanilla uids are 22-char base64; no collision possible). The prefix is baked into saves — never rename it. - Register convergently at plugin Awake, a
QuestEventDictionary.Loadpostfix, aLoadEventListprefix and player-ready —Load()wipes registrations, and a missing signature atLoadEventListsilently drops the row from the save. - Register unconditionally, feature on or off, and purge your rows from the incoming save list when the feature is off — otherwise a guest without the signature hits NK-F3.
- Presence is
stack > 0. A stored 0 comes back as 1 (present) after a save/load or a join-sync — clear a flag withRemoveEvent, never by setting its stack to 0. - Register a signature before its uid can appear in a save or on the sync wire. A row whose exact
uid is unregistered at
LoadEventListis dropped from the save for good on the next write, and on a guest it is skipped (StoryKit guard) or wedges the join (no guard). Uids built at runtime — e.g. a player uid folded in — therefore cannot be pre-registered by name; register them by prefix in theLoadEventListprefix and the sync-guard prefix, or keep that datum in a homemade store. - Per-character progress needs the player uid folded into the uid, or the homemade store. World scope is v1; player scope is designed and deferred.
Design shape: story.<questId>.<stageId> savable events for stage, stackable for counters, timed
for deadlines; the current stage is derived from the store — no parallel truth. Journal entries
(Quest items) remain out of scope for authoring.