Download

← All updates

Update

quark 1.18.0

Everything that changed in this version, and why.

1.18.0 is a latency and performance update. It started as a full audit: 20 auditors each took one small area of the game (frame loop, world drawing, HUD, text, menus, models, renderer, light bake, netclient, protocol, movement, collision, server tick, server loop and snapshots, game modes, bots, audio, demos, the listen server), checked the existing logic for useless loops and branches, then looked for speedups. After that came the audio path, then the whole chain from the mouse to the light leaving the screen, measured with a new F3 graph and tuned against the owner's tests on a 240 Hz monitor with an 8000 Hz mouse.

  • Network protocol unchanged (42): 1.17.0 and 1.18.0 games and servers play together, and demos are unaffected.
  • Same simulation: every server and movement change gives exactly the same results as before. The few changes you can feel are all toward lower latency and are marked below.
  • Your settings: VOTE START moves from F4 to F5 in saved configs (F4 is the new latency number), unless you put something else on F4 yourself. Everything else you set stays as it was; new installs on Windows start on LATENT SYNC.

Owner's results on their PC (exclusive fullscreen, 240 Hz, no vsync, LOW LATENCY ULTRA), as F3's mouse-to-photon graph measures them:

StepMouse to photonAvg fps0.1% low
Borderless before the input work5.2 ms
Exclusive fullscreen2.6 ms
ULTRA paced like Reflex2.66 ms35472526
Late latch2.58 ms41922577
HUD built before the late read2.55 ms40343248

About 2.08 ms of each is the monitor scanning down to the crosshair (half a refresh at 240 Hz), which no game can change. Player shadows measured 74 µs of what was left before they were moved ahead of the late read too.

Display and input

Latent Sync (new PRESENT setting, Windows; the default for new installs)

  • What it does: one frame per refresh. The frame starts late enough that its mouse read is fresh, and early enough that even the slowest frame of the last few seconds is done in time. Then the finished frame is held and flipped, with no vsync, just as the monitor's vertical blank begins. The tear line falls in the blank, off screen, whatever the frame took.
  • How it knows where the monitor is: the display driver reports the scanline (D3DKMTGetScanLine). The next blank is worked out from one read per frame. Checked on the owner's 240 Hz screen: the prediction holds to within a few µs.
  • It adapts by itself (the owner asked for this instead of a hand-set timing): the lead is the slowest frame of the last few seconds, from mouse read to GPU done, plus 100 µs. It eases back down after 3 s without a frame that slow.
  • A PC that can't keep up: if frames take more than 90% of a refresh, Latent Sync runs unsynced (it tears instead of stuttering, like adaptive vsync) until they're quick enough again.
  • TEAR LINE OFFSET (US) slider (shown when Latent Sync is on): moves the flip, for a driver whose flip lands a little off the blank. 0 is right.
  • The trade: one mouse read per refresh instead of one per frame. A movement waits on average half a refresh (2.08 ms at 240 Hz) for its frame, so Latent Sync measures about 2 ms more than NO VSYNC on a fast PC. That's the cost of no tearing. Classic vsync holds a finished frame up to a full refresh on top of that. (Idea from Special K's Latent Sync.)
  • Linux and macOS have no scanline API: their default stays NO VSYNC, and the option isn't offered there.

Late latch: the mouse read again just before the world is drawn

  • Before: the mouse was read at the start of the frame, then the network, ticks, prediction, camera and the whole world's geometry were built before anything was drawn.
  • Now: right before the world's draw calls, the newest mouse motion is read and only the view is rebuilt (the world and players are in world space, so nothing else changes). Your weapon model is carried to the new aim at draw time, so it never lags the world.
  • Culling is padded about 3.5 degrees while the aim can still move, so nothing at the screen's edge pops in a frame late on a flick.
  • The command sent to the server that frame keeps the aim it had; the next one carries the new one.
  • Name tags, health bars and damage numbers (placed on screen from the 3D view) use the aim from just before the read, a sliver of a turn behind. Owner: "its okay for the UI to be 1 frame behind the gameplay".

HUD and shadow map built before the late read

  • The HUD, F3 / F4 and their upload to the GPU are built before the late read (they don't depend on the aim). After it, only one 2D draw call is left. HUD + TEXT took 87 µs after the read before; HUD DRAW is now 0 µs. The saving in mouse to photon was about 30 µs, because on ULTRA the GPU was already drawing the world while the HUD was built.
  • The frame's dynamic geometry upload and the shadow pass also run before the read (the shadow map depends on where players are, not where you aim). On ULTRA the GPU is told to start on the shadow map at once. Player shadows had measured 74 µs of mouse to photon (2486 µs on vs 2412 off).

LOW LATENCY ULTRA paced like NVIDIA Reflex

  • Before: ULTRA waited for the GPU after every swap, so the CPU and GPU took turns and fps dropped.
  • Now: it learns the GPU's time per frame and the CPU's time from input read to swap, and starts each frame just early enough to reach the GPU as the GPU finishes the last one. Still at most one frame in flight, so input is just as fresh, without the idle GPU. The owner's ULTRA GPU wait went from 31 µs to 4 µs.

Raw mouse input on its own thread (Windows)

  • A small thread owns a hidden message-only window registered for the mouse's raw input, so every report (8000 a second from an 8 kHz mouse) is handled there. The game takes the summed motion once a frame with a few atomic exchanges.
  • Why: each report was a message through the game's own event poll, 60-80 µs of every frame on the owner's PC. A batched read (GetRawInputBuffer) was tried first and gained nothing: each report's message still went through the game's queue.
  • While the game has the mouse, the hidden cursor is held still at the window's centre (clipped to one pixel) instead of GLFW's captured mode, which moved the cursor back to the centre after every frame's motion (a further ~50 µs a frame).
  • Motion that arrives while the window isn't focused (alt-tabbed) is dropped, and the cursor is let go.
  • Linux and macOS (and the UI test) keep GLFW's raw motion.

Other input-to-display changes

  • Driver vsync in windowed and borderless: GLFW 3.3 replaces vsync in a window with a wait for the desktop compositor (DwmFlush) before each swap. The frame was handed over a refresh late (7 ms at 144 Hz, 17 ms at 60 Hz), and adaptive vsync never tore. The game now sets the driver's swap interval itself.
  • Your own position is drawn as of now: it was drawn between the last two ticks, one tick behind (1 ms at 1000 Hz, 8 ms at 125 Hz). Now the last prediction is carried over the part of a tick since, with the same movement code. Visual only.
  • The network is read before the mouse: snapshot decoding and prediction replays used to sit between the mouse read and the camera.
  • Held fire keeps the server's rhythm: each held shot was timed from the frame, so sounds and tracers drifted later over a burst (up to ~70 ms over a rifle burst).
  • The weapon kick recovers per second, not per frame (it was 8 ms at 1000 fps and 133 ms at 60 fps).
  • Precise frame cap: the cap now waits on a high-resolution timer, then a short spin. Sleep(1) woke on the next 1 ms tick, so capped frames landed up to 0.5 ms late.

MAX FPS is a slider

  • Before: fixed steps (60, 120, 144, 165, 240, 360, 500, 1000, unlimited).
  • Now: drag or type any cap, 0 = UNLIMITED (the default). Right-click sets it to your screen's refresh rate. G-Sync / FreeSync players usually cap a few fps under that, which the steps couldn't do.

GPU MAX CLOCKS (Windows, NVIDIA)

  • Writes NVIDIA's "Power management mode: Prefer maximum performance" into the game's own driver profile (made if there's none), through NVIDIA's driver settings API. Off removes that setting from the profile.
  • Why: at thousands of fps the GPU is idle about half of every frame and may clock down, which lengthens the GPU part of every frame. The driver reads it when the game starts. If Windows refuses, the game says to run it as administrator once.

Audio (Windows)

  • WASAPI output, shared mode, woken by the audio engine, with one engine period queued (10 ms). It reopens the default device when it changes. WinMM stays as the fallback.
  • Why: WinMM needed a 30 ms queue on top of its own buffering. Shorter WinMM queues were tried at the owner's request (a 15 ms adaptive start, 2 x 10, 4 x 2.5, 5 x 2.5, 1 x 10, 2 x 7.5 ms) and broke up on their PC, because Windows mixes in 10 ms periods. With WASAPI the owner tested 1-20 ms (QUARK_AUDIO_MS); 10 ms was clean, less got worse.
  • EXCLUSIVE AUDIO setting: WASAPI exclusive mode at the device's shortest period (3 ms at 48 kHz on the owner's PC). It skips the Windows mixer and its 10 ms period. Other programs (Discord, music) are silent while it's on. If the device is busy it falls back to shared and says DEVICE BUSY (BUSY on the settings screen). It sits beside GPU MAX CLOCKS: in the left column it ran off the bottom of the screen at 720p and 1080p (UI test).
  • QUARK_AUDIO_MS environment variable overrides the shared queue, for finding where a PC starts cutting out.
  • Sound gains are worked out before taking the mixer's lock, sounds you can't hear never take it, and the listener position takes no lock. The game thread could wait a whole mix for it before.
  • The voice list is reserved up front (it grew under the mixer's lock early in a session).

Performance overlay

F3: the mouse-to-photon graph

  • Under the F3 numbers, every part of a frame is a bar on one time line in microseconds, averaged every 0.5 s.
  • Measured: the CPU stages, from timestamps the main loop marks (frame cap, ULTRA pacing or Latent Sync waits, network read, input read, ticks and prediction, world + HUD + shadow map build, draw calls, HUD draw, swap, ULTRA's GPU wait), and the GPU, from timestamp queries around the frame's GL work.
  • The mouse: its report interval from its measured rate. The rate shown is the highest standard poll rate seen (125 Hz x 2^n: 125 to 8000), counted from 10% under it, so 7200 Hz reads 8000.
  • The wait for the read: half a frame on average. With the late latch on, it's measured up to that read (WAITING FOR THE LATE READ).
  • Vsync (VSYNC and ADAPTIVE VSYNC, Windows): each frame's flip is worked out from the monitor's real blank timing (the same scanline Latent Sync reads): the first blank after the GPU finished it and after the frame queued ahead of it. It shows as VSYNC: HELD FOR ITS BLANK. Frames queue one per blank only when the game runs at the refresh rate, at most two blanks behind. Faster, the driver keeps the newest frame (fast sync) and each frame flips at its own next blank. (A first version queued every frame and its held time grew without end.)
  • Worked out (EST): the compositor's wait in a window, and the scanout down to the crosshair (half a refresh). Monitor processing isn't included.

F4: the number alone

  • F4 shows only MOUSE TO PHOTON ~N US, updated twice a second. It runs the same measuring as F3 but draws none of F3's text or bars, so comparing the two shows what the overlay costs (the owner: close enough not to matter). F4 also calibrates the GPU clock itself (before, it relied on F3 having been open).
  • VOTE START moved from F4 to F5. Saved configs move it (bind_version 3), unless something else was bound to F4.

F3's other numbers

  • The percentiles use a partial selection instead of a full sort, in one pass, and the text is rebuilt every 0.5 s. Its own once-a-second spike sat in the 0.1% low it measures.
  • The version / server line refreshes every 0.5 s, and the GPU clock is calibrated every 10 s (a synchronous query that can stall the driver).

Servers and simulation

All give exactly the same results. They run on dedicated servers and on the server thread inside your game when you host.

Server benchmark on the owner's PC at 1000 Hz (before → after):

TestBeforeAfter
Harbor, 12 bots + 4 clients: avg / p99.9 / worst tick21.5 / 82.9 / 370 µs14.5 / 66.6 / 215 µs
Hamlet, 24 bots + 8 clients: avg / p99.9 / worst tick57.6 / 224 / 4125 µs48.5 / 158 / 1747 µs
Harbor snapshots, 16 clients: avg send55.5 µs39.2 µs

Visibility rays per second were identical in both builds (553,050 on Midtown), confirming the snapshot path sees the same thing.

  • One thread-local lookup per tuning fetch: MinGW (the Windows build) compiles thread_local as a function call per access plus one for its init guard, and movement, projectiles and bots fetch the tuning constantly. Now one pointer per thread. (Its first version kept the tuning in a thread_local object with a destructor, and that crashed at thread exit under MinGW: the object's memory was freed before its destructor ran. It's now allocated once per thread and never freed, a few KB per thread.)
  • Movement: tuning, yaw sine and cosine fetched once per command (a command runs up to 8 one-millisecond steps, and each step fetched the tuning about ten times), and no ground probe while rising (its result could never be used).
  • Rays stop half a metre above the highest brush and below the lowest: the collision grid is 2D, so an upward ray (sun, sky, sight, a grapple at the sky) walked every column to the edge of the map.
  • FFA / instagib spawn pick: rays only for spawns that can still win. It cast a ray from every player to every spawn on one tick (thousands on Lodestone with 32 players). Same spawn, same random numbers.
  • Hitscan: each target is rewound once per shot (every shotgun pellet and ricochet walked each player's history again).
  • Grapple latch sight check stops at the first wall (same answer as the nearest-hit ray).
  • Projectile step: one weapon table lookup, not two, per 1 ms step.
  • Server loop: status read 4 times a second and the reachability probe 100 times a second, with a pause instruction while spinning. The last 0.3 ms before each tick is a spin, and each pass took two locks, copied two strings and polled a socket.
  • Windows: high-resolution timer for the server's sleep: select only wakes on the 1 ms system tick, so 1000 Hz ticks could start up to ~1 ms late.
  • Windows 11: opted out of timer throttling: Windows 11 drops the 1 ms timer for a minimised or covered window, so a hosted server could tick in 15 ms bursts.
  • Dedicated server: raised priority, and no lock per pass for console input.
  • Packets: strings copied whole, and resent commands already handled are skipped (clients resend unacked commands in every packet).
  • Snapshots skip quantising unchanged positions and angles (a library call per player per client per snapshot; the same bytes go out), and the event resend stops once the packet is full (it kept scanning up to 256 events per client).
  • Bots: cheaper floor probes, landing probes only when needed, the weapon mask read once. The probes ran every tick even when their answer was unused.

Client: frames and drawing

No memory allocations per frame are left in a match's paths below, and fewer rays per player. Same picture.

  • Projectiles and events reuse their lists (a new vector every frame, then copied). The HUD's sticky counter reads this frame's projectiles instead of rebuilding the list.
  • Stuck knives, bolts and syringes in players use this frame's players, with the team colour worked out once per owner (each prop re-interpolated its player and scanned the player list twice).
  • Sun ray per player only when its colour can be seen: not for you in first person, nor for players off screen.
  • Health-bar sight rays (9 per enemy) only for bars on screen (they ran for enemies behind you too).
  • Tag marker uses this frame's players (a second full interpolation before).
  • Crosshair built on the stack with its ring directions computed once (3 to 9 allocations and 48 trig calls every frame).
  • Kill feed and chat lines are drawn without a copy and sort per line, and tourney bracket names by reference.
  • HUD widgets: the file path is only built when the once-a-second check is due, and values only when widgets or the verbose HUD show them (path allocations and a 2,048-entry ring walk every frame by default).
  • Ranked lifetime stats: the id list is built only when sent (a dozen allocations and a quadratic search every frame).
  • Race: past runs are read at map load and finished runs written on a background thread (the file was read at the first checkpoint and written on the finish frame).
  • Mode pick uses the menu's map info (it loaded and parsed map files, collision grid and all, on the click, in a match too).
  • Info pages are wrapped once per page and size (re-measured word by word every frame), and the HUD file isn't reloaded on a mode change when it's the same file.

Renderer

  • Shadow pass covers only where the casters are: per triangle, clipped to the shadow map, and skipped when nothing lands in it. A far player used to stretch the area over the whole map, and with it the 4 shadow taps for every pixel; the full shadow texture (16 MB at the default size) was cleared every frame regardless.
  • One dynamic upload per frame, laid out shadow only, lit casting, lit not casting, view model (the lists were copied together and the players uploaded twice, orphaning the buffer three times a frame).
  • Text: each UI quad's corners, texture coordinates and colour are converted once and written in place (about 32 library calls per glyph before, thousands of glyphs a frame on the scoreboard and menus). The UI buffers start at 32k vertices so a first scoreboard or console mid-match doesn't regrow them.
  • Boxes: 8 corners and 3 normals per box, written in place (24 corners and 6 normals before, with 36 capacity checks).
  • Override model lists grow geometrically (each mesh past the high-water mark copied the whole list), and their outline width is set (it was read from uninitialised memory).

Netcode

  • Clock and jitter from the newest snapshot of each frame: all snapshots read in one frame got the same timestamp, so frame time was measured as network jitter. You can feel this one: remote players are drawn 7 ms (144 fps) to 16 ms (60 fps) closer to the present, and lag compensation rewinds that much less.
  • Prediction reconciled once per frame against the newest snapshot: after a hitch, every queued snapshot replayed all unacknowledged commands. Smoothing only.
  • Demo recording reuses its buffer and snapshot copies (a 64 KB allocation and zero-fill per recorded snapshot, 64 MB/s at full rate), and the jitter percentile uses a fixed scratch array.

Light bake and loading

  • Buried-face test uses an XZ grid over all brushes. It tested every brush for each of up to 81 points per face: billions of tests on Midtown and Lodestone, on every load including cached ones. Same faces.
  • Leaving during a load is checked through the prepass (it froze the window until the prepass finished).
  • Biggest faces baked first (a big floor handed out last ran on one thread while the others waited; same texels), with one core left for the game, and the loading screen redraws at about 30 fps.

Fixes

  • Crash at thread exit (Windows): the per-thread rules cache above crashed when a server thread ended, for example when a hosted game ended. It showed up as the UI test stopping in its team vs scenario and the modes test group crashing.
  • EXCLUSIVE AUDIO ran off the bottom of the settings screen (UI test, all three sizes).
  • demos/versions.txt (which game versions ran each protocol) is written the same on every OS: Windows wrote line endings, which failed a test there.

Not changed (found, left for later)

  • Tag: the list of players who are out is never cleared, so in a second tag match on the same server everyone knocked out earlier is out again at once.
  • Payload: when team B attacks a map without staged spawns (Railyard, even rounds), the cart's collision box sits at the other end of the track.
  • The light bake copies the cart's collision box, so its shadow at join time is baked into the cached lighting (fixing it needs a bake version bump).
  • Translucent parts of weapon override models are never drawn in first person.
  • With 64 sounds playing, the drop-the-oldest rule can drop a fresh announcer line.
  • Bigger ideas: spread bot perception over ticks, flat route data for A*, instanced boxes on the GPU, fewer repeated brush tests per ray, one input packet per frame (needs the owner's call), the host's own packets in memory, string views in the text API, LTO.