Skip to content

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 (singleton MonoBehaviour, static dictionary keyed by uid)
  • Definitions: QuestEventDictionary — 1,971 events in 80 sections, loaded from a TextAsset
  • 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 catalogue data/registries/quest-events.json; a flag: 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 says Savable. 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; SyncedEventOnce is set at the end.
  • Serialized form: "<stack>" or "<stack>;<activationTime>;<lastUpdateTime>" for timed events. ParseData only 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 use RemoveEvent, never SetQuestEventStack(uid, 0).
  • Scope: per world save slot, shared by every character in it. Not per-player.

Multiplayer

  • A guest's AddEvent / SetQuestEventStack is an RPC request to the master, which applies it and broadcasts. RemoveEvent / ReduceEventStack broadcast 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 once SyncedEventOnce is true.
  • The wedge (NK-F3): SendQuestEvents is one-shot. If a uid arrives that the guest has no signature for, vanilla NREs, aborts before SyncedEventOnce, 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:

  1. Raw: QuestEventManager.Instance.HasQuestEvent(uid) (null-guard the instance).
  2. 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) returns Unresolved | Saved | Razed; queststate prints it.
  3. 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_CierzoDestroyed
    
    flag: accepts a uid or (from DR 0.1.17) a name; flagage:NAME>H / flagstack:NAME>=N follow.

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:

  1. Prefix every uid <mod>.<feature>.<name> (vanilla uids are 22-char base64; no collision possible). The prefix is baked into saves — never rename it.
  2. Register convergently at plugin Awake, a QuestEventDictionary.Load postfix, a LoadEventList prefix and player-ready — Load() wipes registrations, and a missing signature at LoadEventList silently drops the row from the save.
  3. 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.
  4. Presence is stack > 0. A stored 0 comes back as 1 (present) after a save/load or a join-sync — clear a flag with RemoveEvent, never by setting its stack to 0.
  5. Register a signature before its uid can appear in a save or on the sync wire. A row whose exact uid is unregistered at LoadEventList is 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 the LoadEventList prefix and the sync-guard prefix, or keep that datum in a homemade store.
  6. 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.