Download

← All updates

Update

quark 1.18.17

Everything that changed in this version, and why.

Data-driven game mode logic and the gamemode editor, the third editor in the series after the bot brains (1.18.11) and the physics editor. The owner wants to make his own modes (scoring, rounds, respawns, win conditions) in an editor with code, blocks, a graph and a table of values for the same thing, as the bot editor has. For an edited mode to change the real game, the server's mode rules now run from slots that a mode's logic can replace one at a time. Design: the mode-editor thread's design.md. No network protocol, map or demo format change; the 13 built-in modes play bit-identically to 1.18.15 (the lockstep below).

Custom modes keep their logic and unknown keys (src/game/customrules.{h,cpp})

  • Why: three programs now write the same custom_modes/<name>.qmode: the game's Host > Custom modes, the physics editor and the gamemode editor. Before, a save from Host > Custom modes rewrote the file from the keys it knew, so it would have deleted the mode editor's logic and the physics editor's keys without a word.
  • What: CustomRules gains extraKeys (key=value lines this build doesn't know, verbatim and in file order) and logic (everything from the first logic line to the end of the file, verbatim, never parsed for keys). toText() writes the known keys as before, then extraKeys, then logic.
  • toNetText() is the old known-keys text, and it is what EV_RULES sends. Clients never run logic, and unknown keys mean nothing to a client of the same build, so the network text stays as small as it was.
  • label= (name#hash8, below) is a known key that is never kept in the file: the server works it out on start.
  • Every trailing CR of a line is now stripped (was one), so a kept line reads back the same. The fuzzer found this on the round trip.
  • Tests: rules test customkeep; fuzz_rules also checks that unknown keys and logic survive a load / save and that the network text round-trips without them; a fuzz seed with a logic block (tests/data/fuzz/rules/logic.qmode).

Mode rules as slots (src/game/gamemodes.{h,cpp}, src/game/modeparams.h, src/game/server.cpp)

  • Why: a mode's logic has to be able to replace one rule (say, what a kill scores) and leave the rest native and fast. That needs each mode's rules split at the same named points.
  • Slots: reset, warmup, matchStart, roundSetup, roundStart, live, tick, kill, stab, leave, spectate, roundOver; queries freezeLength, roundLength, roundsToWin, respawnDelay, canDamage, canHit, frozen, spawnType, teamLocked, canAutoStart.
  • Objects (native mechanics a mode switches on and hears back from as events): bombStep (plant / defuse / explode), cartStep (cartCheckpoint / delivered), pointStep (capture), raceStep (checkpoint / finish), neutralCartStep, stalemateDrain, the rock paper scissors tiebreak, the bracket's walkover and the duel modifiers. The bomb's plant raycast and the cart's physics stay C++: what they mean (who wins, and why) is what a mode edits.
  • Numbers: modeparams.h (new) lists every number the native rules use in one QUARK_MODE_PARAMS(X) table (name, type, unit, range, default per mode, doc), with a constexpr ModeParams table per built-in mode. gamemodes.cpp reads s.mp_->plantTime and so on where it used constants: one L1 load each. A file that only changes numbers runs fully native with its own table.
  • Speed: native slots are switches on the mode (the old jump tables), not function-pointer tables, so the per-command queries stay inlinable. Each slot tests Server::modeHooks_ first, which is null for every built-in mode: one predicted-not-taken branch. frozen is asked once a tick into a mask, not per command. serverbench shows no change in the native tick cost.
  • server.cpp: killPlayer keeps the engine's stats (deaths, kills) and the scoring moves into the kill slot, after EV_KILL and the bomb drop (nothing reads the scores in between). damagePlayer's race and "it" branches became rulesCanHit / rulesStab. rulesRespawnDelay gets the player and rulesCanDamage the weapon, so a mode's logic can decide per player and per weapon.
  • Native code was refactored so a slot can be replaced on its own: pickSeeker / pickRandomIt, tourneyStart / tourneyMatchWon, the payload's delivered raised once per round, capture raised 1-based, and LIVE runs timers, tick and timeUp in that order.
  • modeRespawnDelay / modeRespawns (gamedefs.h) are now constexpr: the ModeParams tables use them for each mode's respawn default.
  • No change in play: a before/after trace of 13 seeded bot matches (one per mode, at 125 / 250 / 1000 Hz, 1500 s each, plus 4000 s of hide and seek) compared every tick's ModeState, rules state, qrand state, each player's state and every broadcast event: byte-identical. Test "rules modehooks": number-only hooks run the native slots with their numbers, and hooked slots are called with their arguments.

The mode logic language (src/game/modelogic.{h,cpp}, tools/modes/)

  • Format: tools/modes/GRAMMAR.md. It is the bot brain language (lexer, typed f32 / f64 arithmetic, if / elif / else, ?:, param, curve, slot) with a logic 1 header and its own catalog of slots, fields and actions, plus state, timer, use <object>, proc (up to 4 typed arguments), for p in <set>, aggregates (count, best), % on ints and p in <set>. Loops walk the 32 player slots and can't nest, so every slot has a static worst-case cost.
  • Built-in modes as logic: common.qlogic (merged under every mode) plus one file per mode: vs, bomb, race, koth, payload, ffa, npayload, duel, arena (the 1v1 mode: a name can't start with a digit, and mode=1v1 in a .qmode reads it), tourney, hide, tag, points. They say exactly what gamemodes.cpp does. tools/modes/embed_modes.py embeds them at build time (a CMake step, like the default brain); a missing file fails the build.
  • Layers: common, then the base mode's file, then the custom .qmode's logic. A file overrides only what it writes. A slot whose text matches the default runs native; only changed slots (and those calling a changed proc) are interpreted.
  • The pipeline: parser and canonical printer (print(parse(text)) == text, byte for byte on all 14 files), a JSON AST (the editor's form), a type checker with line:col errors and warnings, a compiler to 8-byte instructions and an interpreter that allocates nothing per call. It talks to the game only through ModeHost.
  • Limits (a hostile or careless file can't stall a server): 4096 instructions per slot, a worst case of 8192 per slot call (a loop body counts 32 times, checked at compile time), 4 nested slot calls at run time (kill() inside tick runs the kill slot; past the depth the native slot runs), 512 state values and 8 timers.
  • logic N has a converter table (kModeLogicVersion 1, none needed yet), as maps, demos and brains do.
  • Label: name#hash8, the first 8 hex digits of a sha256 over the mode's canonical keys (not its name, map pool or label) and its merged logic without comments. Two servers showing the same label play the same rules.
  • Cost (quark_tests modebench: every slot of each default forced into the interpreter, 16 players): mean 170 ns per interpreted slot run. That fits easily in the design's budget of 1 us for an interpreted tick, where a server's whole simulation is about 4-10 us a tick.

Logic on servers (src/game/server.{h,cpp}, gamemodes.cpp LogicHooks)

  • LogicHooks implements ModeHooks and ModeHost over the Server. Every field and action of the catalog maps to the native state and mechanics, and announcements also go to the game log. Params are the base mode's built-in table overridden by name.
  • ModeHooks for the interpreter: a bitmask of interpreted slots; event() returns false to run native (the depth limit); a read-only query(); nativeLifetime() so the logic's state resets when reset / matchStart / roundSetup run natively. frozen is queried per command only when it is interpreted.
  • Server::start compiles the custom mode's logic once. A compile error refuses the start with its line:col, which the host screen shows. The logic plays only while the server plays its base mode (the log says "not played" otherwise).
  • Never on ranked or time trials: start() now clears the custom rules for time trials as it already did for ranked, so neither can play custom numbers or logic. Their results reach the leaderboard, so they must play the standard rules. (The game never started a time trial with a custom mode; this makes the server enforce it too.)
  • The label everywhere: EV_RULES carries label=name#hash8; the server list name is [name#hash8] <server name>; the start log line names the label and the interpreted slots; each join log line says "playing mode logic name#hash8".
  • Server::presetModeLogic(text, base, err) loads logic before start: the editor's sim helper uses it.
  • Where it runs: games hosted with a custom mode (listen servers, unranked). Dedicated servers still have no custom-mode option.
  • Clients never run mode logic. Everything a client shows already travels in ModeState, the score fields and the existing events, and the HUD follows the base mode, so the protocol stays the same and older clients joining just see the numbers they understand.

Client (src/client/game.cpp)

  • Host > Custom modes: a mode with logic shows LOGIC: name#hash8 under the TIME / SCORE / ROUNDS hint, or the compile error in red. It is recompiled at most four times a second and only when the text changed (the hash covers the keys too), so the menu's frame time doesn't move.
  • The CUSTOM MODE notice on joining adds - LOGIC: name#hash8 when the server plays logic.

Tests (tests/mode_tests.cpp, tests/rules_tests.h)

  • Group modelogic: round trips of every default, compile errors with line:col, the converter, every default compiling all-native, and the interpreter against a test double.
  • Group modelockstep: two servers, one native and one with every slot forced through the interpreter, for all 13 built-in modes at 1000 / 250 / 125 Hz, 60 s each, with a coach driving the objectives. ModeState, the rules bookkeeping, qrand and the events are compared every tick: 1,090,375 ticks, 0 differences. Also an interpreted KOTH against a server with no hooks at all, and a negative control: a kill slot that scores 2 is caught.
  • Logic on servers: an unranked server plays it and shows the label; ranked and time trials drop the custom mode and its logic (a preset one too); a compile error refuses the start.
  • Command-line tools for the editor: quark_tests exportmode (the catalog: types, slots, fields, actions, objects, params with units, ranges and each mode's default, limits; plus every default's text, AST, hash and slot costs), checkmode <file> [--map <name>] (diagnostics as JSON; with a map, an error for each map feature the logic needs that the map lacks), modeast, printmode, modebench.

Sim helper (tools/mode_editor/native, linked into quark_maptool; tooling)

  • quark_maptool modesim: one headless match of a mode on a map with bots. It runs the game's own Server with the request's .qmode, stepped tick by tick with no sockets and no real-time clock, so it is deterministic by seed. Output is NDJSON: start, frames (phase, round, clock, scores, objective state, players), events (kills, rounds, plants, captures, checkpoints, tags, overtime) and an end line with the winner, scoreboard and perf (times real time, server ns per tick avg / p99 / max). It can be paused, stepped, stopped and sped up over stdin.
  • modebatch: N seeds on every core but one, one fresh thread per match (batch seed N gives the same match as modesim seed N). It reports team win rates, draws, timeouts, lengths, score spread and per-bot averages, plus CSV.
  • modecheck: compiles a .qmode as modesim does and lists the map features it lacks.
  • Formats: MODESIM.md; tests: test_modesim.py (every mode on its own map, determinism, rules take effect, logic labels and errors, controls, batch == modesim).

The gamemode editor (tools/mode_editor/; tooling, not in the game download)

  • A local page on 127.0.0.1:8771 (run.cmd / run.sh; install.py installs or updates it into Quark\gamemode-editor, keeping the user's config and provenance). Its look follows the bot editor's brain page. The server is Python, standard library only; nothing is fetched from the internet.
  • One AST (common + base mode + the file) behind seven views: Code (highlighted, line:col errors, canonical re-print, draft kept across restarts), Blocks, Graph (per slot or proc, plus an overview), Values (mode keys, params with unit / range / built-in default, state, timers, objects, curves), Diff against the built-in (an ordered line diff, with resets), Phases (warmup / freeze / round / overtime / round end / match end, each slot shown as interpreted, native or not written) and Sim (modesim and modebatch streamed live; when the build has no modesim the view says so and simulates nothing).
  • The catalog comes from the game: quark_tests exportmode. Without a build it falls back to a snapshot taken from one (snapshot_catalog.py), and the header says SNAPSHOT CATALOG. With the live catalog the editor's errors and warnings match checkmode on all 13 built-ins.
  • Saves never damage another editor's lines: the rule agreed with the physics editor. This editor patches only custommode / mode / timelimit / scorelimit / rounds / maps and the logic block, and keeps every other line byte for byte and in place (CRLF too). It never writes a key line after logic. It refuses (409) a save over a file that changed on disk since it was loaded, and (400) one that would touch a foreign line. A save writes only what differs from the built-in, so a file loads and saves to the same text.
  • checkmode (or modecheck) compiles the file the way the game will when the build has it. serve.py runs a quark_tests command only after finding its name in the binary, because an unknown command would run the whole test suite.
  • Authorship: each save records gamemodes/<name> in the editor's provenance.json ("ai" unless "I wrote this myself" is ticked; below).
  • The JS is adapted from tools/bot_editor/web/js (each file names its source); the bot editor itself is unchanged. Tests: node tools/mode_editor/web/test/run.mjs (text <-> AST <-> blocks <-> graph round trips on all 14 defaults, the save rules, the sim formats).

Provenance (tools/provenance.py, assets/provenance/gamemodes.json, docs/PROVENANCE.md)

  • New category gamemodes: one item per built-in mode logic file, gamemodes/<name> from tools/modes/*.qlogic (14 items, common included, all ai). Mode logic is something the owner can now replace by hand, like the bot areas, so it counts toward how much of the game is human-made. sync reads the items from the source (no build needed) and keeps a custom mode an editor names (a curated gamemodes/ entry from a merge). mode-editor is a known tool.
  • The human-made share goes from 17.8% (100 of 561) to 17.3% (100 of 575), because 14 AI-made items were added. Nothing was replaced.

Editor Manager (tools/editor_dashboard/dashboard.py)

  • The gamemode editor is listed after the model editor (folder gamemode-editor, its sidecar's tool mode-editor, page http://127.0.0.1:8771/), and tools/mode_editor/ and tools/modes/ count as its files.

Docs

  • docs/RELEASE_CHECKLIST.md: a gamemode editor row (host a custom mode with logic and see its LOGIC label; ranked ignores logic; the editor opens on 8771, its saves keep the physics lines; Sim runs).
  • docs/HANDOVER.md: tools/modes and tools/mode_editor in the repository layout. docs/PROVENANCE.md: the gamemodes row.