1.18.5 is the second performance pass over the client. 14 auditors each read one small part of the game (the frame loop, the world drawing in two halves, the 3D renderer, text and 2D, the HUD, the in-match menus, input, sound, netcode, F3 and the GPU settings, loading and the light bake, the menus, the editor), first for useless loops and branches, then for speedups and bugs. The changes that give the same picture and the same game were applied; the ones that change how the game plays were fixed only where they were clearly bugs, or where the owner chose. The server's own pass is 1.18.3 / 1.18.4.
- Network protocol unchanged (42). One netcode change (reliable events) needs no server change to work, and pairs with 1.18.4's server half.
- Simulation unchanged: every test, including the hashed per-tick ones, gives the same results.
- Costs below were measured on the owner's PC (Windows, MinGW, RTX 3090 Ti, 240 Hz, 8 kHz mouse) unless marked as estimates.
Loading
Shipped light bakes (owner)
- Before: every official map's lighting was baked on the player's PC the first time it was joined after an update (the cache is keyed by the map's content, and maps change often): Midtown 4.9-5.3 s, Lodestone 2.6-2.8 s, all 21 maps about 24 s on a 20-thread PC, 2.5x that on 8 threads.
- Now: CI bakes the official maps once (
quark --bake-maps <dir>, headless, no window or GL), packs them zlib-compressed (tools/pack_lightcache.py, 87 MB → 11.7 MB) and every platform's package carries them inmaps/lightcache/<sha256>.qlbz. On a cache miss the game inflates the shipped bake (about 21 ms for Midtown) instead of baking: Midtown's first join 5.26 s → 0.05 s, Lodestone 2.79 → 0.04 s. Shipped bakes are read only (never written, touched or evicted); a map without one bakes as before. .qlbz, not the user cache's.qlm: the launcher keeps every.qlmas a player file through updates, so old shipped bakes would pile up;.qlbzare game files updates replace.- Packages grow by 11.7 MB each. CI has a
lightcachejob before packaging; a missing bake only warns (it means a first-join bake, not a broken release). Local builds (build_windows.ps1, so alsobuild-releases.cmd) bake intobuild-win\lightcache: about 25 s the first time, under a second when no map changed. - Tests: a shipped bake is used without touching the user cache; a stale bake version, another map's bake, a cut stream and raw bytes under the compressed name are each refused and baked as usual.
Faster cached loads
- The bake's prepass (which faces are buried, which edges and lifts) ran on one thread on every load, cached or not: 110-180 ms on Midtown and Lodestone. Its three passes now run on every core but one, in chunks, with the same output (vertex hashes identical): cached Midtown 111 → 41 ms, Lodestone 107 → 38 ms.
- The bake's progress loop and the loading screen waited in 30 ms sleeps (MinGW's
sleep_forignores the 1 ms timer, so 31-47 ms): a finished bake waited up to that long to be noticed. Both now wait in 1 ms steps. - The cache file is touched on use, so eviction (the 32 newest) keeps the maps you play, and written atomically (a temporary file, then a rename: a power loss can't leave a zero-filled cache that loads as black lighting).
- glTF models: accessor sizes are range-checked (overflow-safe) before allocating, and normals / colours whose count doesn't match the positions are ignored (they were read past the end).
The frame and its latency
Mouse reads
- The late mouse reads (the late latch, and ULTRA's read before the camera) take only the motion now. The window focus check (a system call) and the twice-a-second cursor re-hold (SetCursorPos / ClipCursor, several µs under the global input lock) ran inside them, so twice a second they landed between the read and the swap, in the 0.1% lows. Both are done in the frame's first read; focus can't change before the next event poll. The cursor is also re-held at once after a resize or a display mode change.
- The cursor is held only when the window has the focus: a capture that happened behind another window (a bake ending, a connect) moved the other program's cursor to the game's centre.
- F3's INPUT->FRAME took the late read's input as the next frame's, about one frame too high; a report racing the read could also lose its timestamp and drop the sample.
Screen mode cached
glfwGetVideoModeasks the display driver every call (EnumDisplaySettings, tens of µs). Latent Sync called it every frame, and F3 and the settings page too. The mode is now read once and again on a display mode change, a resize or another monitor.
ULTRA pacing
- Bug: ULTRA learnt the CPU's time per frame from the input read to after its wait for the last frame on the GPU. When the GPU was the bottleneck that wait was always there (the target starts a margin early), so the learnt CPU time grew about 15 µs a frame until the pacing stopped pacing, adding about a GPU frame of latency. The CPU time now ends at the swap, before that wait, and without Latent Sync's hold for the blank.
- The LOW LATENCY ON fences no longer run under ULTRA pacing (Latent Sync) where they did nothing, and their extra flush is gone (the next swap flushes it).
Latent Sync
- The lead (how early a frame starts) was the single slowest frame of the last 3 seconds: one hitch, or a loading-screen frame, held every frame's mouse read milliseconds early for seconds. It's now the 3rd slowest of the last 256 frames (about a second at 240 Hz), loading-screen frames not counted, each clamped to a refresh: one or two slow frames tear once instead.
- Unsynced (a PC that can't keep up), it no longer drains the GPU every frame (it serialized CPU and GPU on exactly the PCs short of speed); it uses ULTRA's learnt times.
- The monitor's scanline clock is reopened when the mode changes and retried a second after a failure (a driver reset stopped Latent Sync for good).
Less after the late read
- The translucent geometry's upload (beams, tracers, lines) and the uniforms that don't depend on the aim (about 55 of 63, with their powf work) moved from after the late read to before it. Uniform locations are looked up once (each call was a hash lookup), and the constant ones are set once at start. relatch only re-aims the camera (the projection is unchanged), and the view model's matrix is made once.
Drawing
Text and 2D
- Every glyph and rectangle made about 12 libm rounding calls (
round/lroundare out-of-line calls in this build): 222 ns a glyph. Exact integer rounding (checked againststd::roundover every float), the colour packed once per string, and integer texture coordinates: 31 ns a glyph, byte-identical vertices. About 160 µs a frame with F3 open, 40-75 µs for a normal HUD. - Buttons, cycles and sliders copy their label only when it must be clipped, and measure it once; a slider formats its number once unless it changed.
World and HUD
- Health bars: a bar wholly off screen no longer casts its 9 sight rays (projection only rejected points behind the camera), and the ray that last found a player hidden is tried first (all 9 must be clear, so the answer is the same; a player behind cover usually costs one ray).
- The per-frame vertex lists are reserved at start (a busy frame used to regrow them mid-frame), and the players' interpolation writes straight into the frame's array.
- The hud.cfg file is watched on a thread of its own (a stat on the frame once a second, 12-17 µs, landed in the 0.1% lows), and through the UTF-8 path: under a user folder with an accented name, edits never reloaded.
- The custom mode editor's mode list is built once (13 strings a frame).
Audio
- The game thread never waits for the mixer. play() took the mixer's lock, which the mixer held for its whole pass (10-100 µs): a sound played at that moment stalled the frame. Sounds now go into a queue the mixer takes at the start of each period (same order, same voice-stealing rule); switching the announcer voice hands the new lines over without the mixer ever reading freed ones.
- 3D sounds follow the listener: their left / right gains were fixed when they started, so a 1.2 s explosion stayed in the ear it began in through a 180° turn. They're recomputed every period, ramped to avoid clicks.
- The mixer's inner loop uses a 32.32 fixed-point position with no per-sample bounds check (long or pitched sounds play slightly more exactly).
- Shared mode at the engine's shortest period (Windows 10+, IAudioClient3), in the engine's float mix format, where the driver allows a period under the default: about 3 ms queued instead of 10. Otherwise (the owner's driver offers only 10 ms) the 10 ms path;
QUARK_AUDIO_LEGACY=1forces it. The startup log names the engine period and why it isn't shorter. - EXCLUSIVE AUDIO also tries 32-bit float, 24-bit and extensible 16-bit formats (some USB DACs take only those), tries the next format when one fails to start, and shows N/A (no format the device takes) apart from BUSY.
Netcode
- Reliable events were lost. The client acked the newest event id it saw; the server resends unacked events at a pace and lets small ones pass a big one that doesn't fit, so a later event could arrive first, the ack covered the missing one, and the server dropped it: kill feed lines, chat, names, ranked tickets, map edits. Events are now handled strictly in id order and acked only up to the last one with none missing before it (demos, which have gaps, take every newer one). 1.18.4's server gives the oldest event priority so it can't be held back.
- The render bracket is found from the newest snapshot back and stops: with INTERP DISABLED (or a stall) it walked all 512 snapshots every frame.
- A prediction mismatch stores the server's state as the acked prediction, so the next snapshot with the same ack doesn't replay every command again (50-300 µs each).
- findPlayer is a table lookup, the jitter estimate starts fresh for each connection and eases in demos, a snapshot with tick rate 0 or over 1000 is refused, and demo records hold at most 255 events (the count wrapped with 256 stuck props), numbered only once written.
Editor
- Only edits that change boxes rebuild the 30 MB static batch on a big map (a title keystroke, a slider move or a spawn nudge used to: 10-30 ms each), and online only when a box really changed.
- A held handle does nothing when the drag hasn't changed, and no longer copies the whole map every frame (80-200 µs on Midtown); actions copy the map without its 470 KB text.
- The static batch keeps its memory between rebuilds (it grew 30 MB from nothing each time, ~16 ms), per-map results (clip boxes, mode support) are cached, and the culling margin from a match is reset.
- Online: when another editor adds or deletes an object of the kind you have selected, your drag ends and the selection follows the same object (an index could point at the next one and overwrite it).
Fixes
- The click that brings the game back after alt-tab fired the weapon. A mouse press in the same event poll as the focus coming back is the window's now; its button isn't down until released. (owner)
- ALT TOGGLE scope: the zoom, the scope overlay and the scoped sensitivity followed the key held, while the server got the toggled state: after a tap the view and the server disagreed.
- Enemy footsteps in mid-air: "walking" was under 0.05 m of height change per frame, so at 4000 fps anything slower than 200 m/s up or down counted (jumps, falls, grapples). It's now a speed (3 m/s, the old 60 fps threshold).
- Killstreaks reset when a match starts again (back to warmup, or another mode) and when a slot's player leaves; they only reset on leaving the server.
- A click just before Esc, chat or the console, or as a map started loading, fired one shot on the first tick after.
- The weapon kick decayed only while the view model was drawn: scoped, dead or with the model hidden it stayed, unscoping replayed it, and shots started up to 9.6 cm back.
- Remote shots and knife slashes start at the shooter's eye at their body size (giant and double size) and crouch; watching a player, their laser and sniper shots were edge on and invisible.
- F3: the GPU clock is calibrated as the halfway point of the query, best of 3, before the first results; the frames' slots are read oldest first (after a wrap the vsync model could ratchet); F3 / F4 start fresh when turned on; with no late read the mouse is fixed at the frame's read.
- The custom mode editor's PLAYS AS skipped back to FFA past ranked arena / tourney, so hide and seek, tag and points couldn't be reached going forward.
- The console set nothing, saved the file and said nothing for an unknown setting with a value; now it says "unknown setting". The announcer is reloaded only when the voice is what was set.
- The selected host / bots map index could point past the list after maps were deleted (the map picker read it unguarded).
- Ranked lifetime stats: the "anyone unknown?" check ran every frame once everyone was known; now once a second.
- Audio: the IAudioClient3 interface was declared in an anonymous namespace with no derived class, which let GCC compile every call through it as unreachable: a build crashed at audio start on Windows 10+ (caught by the UI test before release).
Found, not changed
- Sorting world chunks front to back, indexed / packed vertices, a static view-model buffer and drawing the view model first: possible GPU savings, but not output-identical or not measured; left for a measured pass.
- The raw input thread could unregister while the mouse isn't captured (8000 wake-ups a second in menus); the snapshot history as a fixed ring; batching input packets on catch-up frames (needs the server owner); following a change of the default audio device.