Skip to content
Tom_XV edited this page Sep 23, 2026 · 8 revisions

English | 日本語

Experimental. This came in with framework 1.4.1 and may change or go away in a later version.

Graphs (DragNWash.ModFramework.Graphs, version 1.5.0) lets you make mods with no code that do things. You write them as when this happens, do these things. For example, "When a scene loads, wait a second, count what it has, write a line to the log." Or "When this dialogue node starts, hide that object." A graph is a JSON file in a folder with a mod.json, like an Overrides mod, and one mod can have both. Nothing gets compiled and none of your own code runs. A graph can only call the Operations the libraries registered, which are the same ones the Console's op command and the Bridge offer.

You make graphs in an editor on the Bridge's page, next to the Code graph. It uses blocks with slots, like Scratch.

The design and research behind it are in docs/GRAPHS.md.

For players

Installing a graph mod

A graph mod is a folder. Put it into the game's BepInEx/plugins folder so it looks like this:

BepInEx/plugins/Scene notes/
  mod.json
  graphs/scene-notes.json

You need the framework with the Graphs library. You don't need developer tools to run a graph, only to make one, because the editor is on the Bridge's page. Start the game and the mod shows up on the Mods screen (Options → Mods) with its name, authors and description, just like any other mod.

What a graph says about itself, before it runs

In the mod's details there's a Graphs tab (1.5.0; before that, a page opened with a button) that describes each graph in plain words:

Scene notes (graphs/scene-notes.json)
  Answers: scene loaded, save written
  Reads:   inspector.objects.children, saves.flags.list
  Changes: nothing
  Needs:   Inspector

Changes is the line to look at. It names every operation the graph might use to change something, and it's read straight out of the file before anything runs. If a graph says Changes: nothing, all it can do is look around and write to the log. The page also shows whether each graph is running and how many runs it has started. There's a Stop for this session button too, which stops the graph and puts back whatever it changed.

If a graph needs a library you don't have (the Inspector, say), it shows needs Inspector and doesn't run. It starts working once you install that library.

Switching it off

  • To stop one graph right now, press Stop for this session on the mod's Graphs tab, or type graphs stop <file> in the Console. Whatever it changed is put back.
  • To turn off one mod, switch it off on the Mods screen like any other mod. This takes effect the next time you start the game (its mod.json is renamed to mod.json.disabled).
  • To turn off all graphs, go to Mods → Drag'n Wash ModFramework: Graphs → Settings → [General] Enabled (it's on by default).
  • To remove a mod, delete its folder.

What a graph cannot do

This isn't just a rule that graphs are trusted to follow. A graph simply isn't built to do these things. It can't call a method of the game by name, use reflection, read or write files of its own, open a connection, start a program, or run forever. It can only call operations that were registered for it, and the only new thing it brings is when. Whatever it changes is put back when it's stopped, reloaded or switched off, and none of it outlives the session. In this version, a graph can't touch your saves at all.

If a graph fails three times in a row, it's switched off for the session, its changes are put back and the Mods screen marks it. The same happens to a mod's event handler that keeps throwing.

For mod makers

The folder

BepInEx/plugins/<Mod>/
  mod.json              name, authors, description, version (as for overrides)
  graphs/*.json         one graph per file
  overrides/*.json      optional: a mod can have both

mod.json is the same file the Overrides page describes. One loader in the core finds both kinds of data mod, so a folder with both shows up as one mod on the Mods screen, and you only switch it off once.

The graph file

{
  "format": 1,
  "name": "Scene notes",
  "description": "Writes to the log what each scene has at its top level.",
  "variables": { "scenes": 0 },
  "on": [ { "id": "h1", "event": "scene.loaded", "do": [] } ]
}
Key What
format required 1. A file of a higher format is refused, with a line saying to update the framework
name, description optional For the Mods screen and the editor; the file's name when left out
variables optional The graph's variables and their first values (text, a number, true/false, null). They live while the game runs; nothing is saved
on required The events it answers: a list of handlers
layout optional Where an editor put each block. The game never reads it, and today's editor doesn't write it

Any other key is an error, so a typo gets caught when the file is read instead of being quietly ignored. Every handler and statement has an id that's unique in the file (the editor makes ones like h1 and s12). Errors, the log and layout all point at a statement by its id, so they still point at the right place after you move blocks around.

Handlers

{ "id": "h1", "event": "scene.loaded", "when": true, "overlap": "skip", "do": [] }

when is an optional filter, so the run only starts when it's true. overlap says what happens if the event comes again while a run is still going. skip (the default) ignores it, and queue starts another run, up to 8 runs of one graph at a time.

These are the events a graph can answer:

Event Values it hands over Comes from
game.started core
scene.loaded scene, mode core
scene.unloaded scene core
dialogue.node.started node Dialogue
dialogue.line.showing line_id, speaker, text Dialogue
dialogue.option.showing line_id, text Dialogue
saves.written slot Flags and saves
timer.every the Graphs library: every seconds (0.5 or more), real time
key.pressed key the Graphs library: the key given, such as F8. F1 is refused

A key is nobody's. Another mod might have a setting on the same key, and then both of them answer it. Since 1.4.2 a graph tells you who else uses its key, in the log, in graphs in the console and on the mod's Graphs tab, like this: F6 is also Drag'n Wash Localization: [Debug] DumpDialogueKey; both answer it. Nothing gets refused, because one key doing two things may be exactly what somebody wants. So read that line, and if the overlap isn't what you wanted, pick another key. (Drag'n Wash Localization uses F6 and F7 for its dumps.)

A run doesn't start inside the event. The values are copied, and the run starts on the library's next frame. That way a graph never runs in the middle of another library's hook, and a slow graph can't slow an event down for other mods. The cost is one frame. A graph can't change a line before it appears, so for that you want a text rewriter (GameText.AddRewriter, Text).

Statements

A statement is an object with an id and exactly one of these keys:

Statement Written What
call {"call": "saves.flags.list", "args": {}, "as": "flags", "onError": []} Calls an operation. The name is written out in full and never worked out while it runs, so you know what a graph can call before it runs. as keeps the result for later statements in the same run. Without onError, a failed call fails the run. With it, those statements run instead, with the message in error
set {"set": "count", "to": 1} Sets a variable listed in variables
if {"if": true, "then": [], "else": []}
wait {"wait": 1.5} Seconds of real time, 0 to 600; 0 is the next frame
repeat {"repeat": 5, "do": []} 1 to 1000 times
while {"while": true, "max": 100, "do": []} At most max rounds (required). If it's still true after max rounds, the run fails
each {"each": [], "as": "item", "max": 50, "do": []} Once per item of a list. A longer list fails the run
log {"log": "hello", "level": "Info"} A line in the log under the mod (Info, Warning, Error); at most 20 a second per graph
stop {"stop": true} Ends this run, not the graph

There's no goto, no statement that makes another handler run, and no recursion. Every run ends, and the limits further down say how soon.

Expressions

An expression is either a plain value ("text", 3, true, null) or an object with one operator:

{"var": "name"} A variable, or a result named with as earlier in the run (error inside onError)
{"event": "scene"} A value the event handed over
{"get": [{"var": "root"}, "children"]} A field of an object, an item of a list (0 first, -1 last); nothing when it is not there
eq, ne, lt, le, gt, ge {"gt": [a, b]}; numbers compare as numbers, anything else as text
add, sub, mul, div Numbers only; anything else, or dividing by zero, fails the run
and, or, not {"and": [a, b]}, {"not": a}
join {"join": ["Scene ", {"event": "scene"}]}; lists and objects are written as JSON
contains Text in text (ignoring case), or an item in a list
length Of text, a list or an object; 0 for anything else

False, 0, "", null and an empty list or object all count as false. Arguments are converted the same way the console converts them, so "3" works where a number is expected.

An example

{
  "format": 1,
  "name": "Scene notes",
  "description": "Writes to the log what each scene has at its top level.",
  "variables": { "scenes": 0 },
  "on": [
    {
      "id": "h1",
      "event": "scene.loaded",
      "do": [
        { "id": "s1", "set": "scenes", "to": { "add": [{ "var": "scenes" }, 1] } },
        { "id": "s2", "wait": 1 },
        { "id": "s3", "call": "inspector.objects.children", "as": "roots" },
        { "id": "s4", "log": { "join": ["Scene ", { "event": "scene" }, ": ", { "length": { "var": "roots" } }, " root objects"] } },
        { "id": "s5", "each": { "var": "roots" }, "as": "root", "max": 200, "do": [
          { "id": "s6", "if": { "gt": [{ "get": [{ "var": "root" }, "children"] }, 50] }, "then": [
            { "id": "s7", "level": "Warning", "log": { "join": [{ "get": [{ "var": "root" }, "path"] }, " has ", { "get": [{ "var": "root" }, "children"] }, " children"] } }
          ] }
        ] }
      ]
    }
  ]
}

In the block view, that handler is one stack under a hat block:

when scene loaded
  set scenes to (scenes + 1)
  wait 1 seconds
  roots = inspector › objects › children
  log "Scene " (scene) ": " (length of roots) " root objects"
  for each root in roots (at most 200)
    if (root › children) > 50
      log warning (root › path) " has " (root › children) " children"

What a graph can call

A graph can call every read in the Operations registry except the page-only ones (code.*, graphs.*). Right now that's the core's mods.list, game.info, scene.list and mods.network, the Tool window's log.read, the Assets lists, Dialogue's dialogue.current and dialogue.recent, Text's text.rewriters and text.shown, Flags and saves' saves.list, saves.flags.list and saves.flags.get, and the Inspector's inspector.objects.find, inspector.objects.children, inspector.components.list, inspector.member.get and inspector.selection.get.

It can also call these writes from the Overrides library. They do what an overrides row does, only at a moment the graph chooses:

Operation What Arguments
objects.member.set A component's field or property path, component, member, value; also index, private, scene
objects.material.set A material's shader property path, material, property, value; also component, index, scene
objects.active.set Shows or hides an object path, active; also scene
objects.writes (read) What this library has changed and who asked, with the mods that changed the same thing

Values are written as text, exactly as the table on the Overrides page says (2.5, true, #RRGGBB, 1, 0.5, 2). Each write remembers the value it found and puts it back when the graph is stopped, reloaded or fails, newest first. Each one is logged at Info as graph:<mod>/<file>. The Inspector's History lists them next to the edits made by hand and says who made each one, and you can put back a single change from its row without stopping the graph. (Undo last and Ctrl+Z skip them, since those are for your own edits, and the export leaves them out.)

Nothing a graph calls lasts beyond the session. Writing a flag into a save (saves.flags.set) is left out on purpose for now. It changes a file, and that needs its own design.

Your own mod can register operations (see Operations), and graphs pick them up without any change on this side. That's how a mod can give other people something to build on without writing code.

The editor

The editor is the Graphs tab of the page the Bridge serves on this computer. Turn the Bridge on in the F1 window's Bridge tab (developer tools) and press Graphs there. The page opens with the editor in front. Open page, next to it, opens the same page at the Code graph, and you can switch between the two views inside the page.

  • The list on the left has every loaded graph, plus New graph. If a graph's file was removed outside the editor, the list tells you, instead of the graph failing when you open it. Reload reads the files again and clears it.
  • The middle shows the graph as blocks. There's a hat block for each handler and a block for each statement, and a call shows the operation's parameters as slots, filled in from the registry. So an operation added by any library shows up here with its parameters, its description and its kind, and writes get their own colour.
  • The page checks as you type, using the same rules the game uses, and lists the problems by statement id, like s2: objects.active.set needs path. It also tells you what the graph changes, in the same words the Mods screen uses.
  • The graph block holds its name, what it says (the description the Mods screen shows) and its variables. A set statement can only use a name listed there, so that's where you add, rename or remove a variable and set what it starts as.
  • A handler block holds its event, plus only if (a filter, so the run only starts when it's true) and again while running (skip or queue).
  • A value that's a colour gets a colour picker. A call to objects.member.set asks the game what type the member is, so the field shows Color or Single next to it. As you drag the picker, the colour changes in the game right away. It's the same write the graph would make, so it's listed in the Inspector's History and you can put it back from there.
  • Save writes the file into a data mod's graphs/ folder. If the mod and its mod.json don't exist yet, it makes them. It keeps the file it replaced as <name>.json.bak, then reloads the graphs. It never writes outside BepInEx/plugins/<folder>/graphs/, and never into a folder that holds a DLL.
  • Run starts the handler named next to it (any of them, not just the first) without waiting for its event, so you can try a graph on the spot. A graph that was stopped runs again when you press Run. Stop ends the graph and puts back what it changed.
  • Rename moves the file, so you end up with one graph and not two. Save always writes the graph where it already is. Delete takes the graph out of its mod and keeps the file next to it as <name>.json.bak.
  • The Log panel shows the library's own lines as they happen. Clear empties the panel (the game's own log isn't touched).

Blocks and Nodes (the two buttons on the right of the bar) are two views of the same file. The node view draws each handler and statement as a box. The flow runs down the edges (next, then, else, do, on error), a result named with as is a dashed wire to whatever reads it, and a write has its own colour. It's there so you can see the shape of a graph. Click a node to open its block, which is where the fields are.

You drag a block by its head. Drop it above or below another block, or on the + add row at the end of a list, and it moves there. That can be into an if's then, out of a loop, or anywhere else a statement can go. A handler only lands among handlers, and you can't drop a block inside itself. The ↑ ↓ ✕ buttons still do the same thing by hand.

The page fits itself to the window it's in. In a narrow window the problems and the log move under the blocks, and in an even narrower one it becomes a single column.

The page draws all of this with its own code. Nothing is fetched from the internet, and no library ships with it.

The console

If you have the Tool window installed, the Console has these commands:

Command Does
graphs every graph: what it answers, reads, changes and needs, its problems, and how it is going
graphs reload puts back what the graphs changed, reads the files again and starts them: for editing a file while the game runs
graphs stop <file or name> ends one graph for this session and puts back what it changed

From C# you have GameGraphs.Loaded, GameGraphs.Reload(), GameGraphs.Stop(which), and GameGraphs.Guid for [BepInDependency].

Limits

A graph shares the game's frame with everything else, so there are limits:

All graphs together 1 ms a frame ([Graphs] FrameBudgetMs), shared between runs; the rest waits for the next frame
Per run 10,000 steps; loops at most 1,000 rounds; a wait at most 600 seconds
Per graph 8 runs at once; 20 log lines a second
Per file 2,000 statements, nested at most 32 deep, 256 KB
Failures three in a row switches the graph off for the session, its changes put back

The whole file is checked before anything runs. That covers its shape, every call naming an operation that exists and is offered to graphs, every required argument, and every {"var"} and {"event"}. If a file has a problem, none of it runs, and its problems are listed on the Mods screen.

When two graphs meet

If two graphs change the same thing, both write and the later one wins. Nobody's left wondering why, though:

  • The log names both of them, once: X and Y both change cars/car_3 (2) active; the value from Y is used (it wrote last).
  • For each graph that met another, the mod's Graphs tab shows Also changed, with the member and the other graph.
  • Putting a value back only undoes what that graph wrote itself. If somebody else has written the member since, it leaves their value alone and says so. What a stop reports is what it really put back, so you'll see stopped for this session with no number if it put nothing back.

It works the same way when an overrides row and a graph change one member. Both write through the same ledger, so they notice each other however each of them spelled the path.

Content policy

A graph holds what a person wrote: the events, the steps and the names needed to find an object. That fits the content policy, which says anything made by hand is fine and game data copied unchanged is not.

Clone this wiki locally