Download

← All updates

Update

quark 1.19.1

Everything that changed in this version, and why.

1.19.1 is the production release after 1.19.0: the testing line 1.19.0.testing.<run> (from test-1.19.0.testing.342 to .392 and the builds after it) promoted as it stands. Every change below was made, explained and built in one of those testing builds. The short version is on the updates page.

  • Network protocol 47 (1.19.0 used 44: 45 acid, 46 the two-box hitbox, 47 rocket riding). A 1.19.1 game only plays with a 1.19.1 server (or a testing build from 1.19.0.testing with protocol 47), so update games and servers together.
  • Map format 4 (1.19.0 wrote 2: 3 adds NOGRAPPLE, 4 ACID). Older maps are converted on load and players' own map files on disk at startup, the originals kept in maps/old/v<N>/; 1.19.0 can't open a format 4 map.

This release

  • VERSION: 1.19.0 -> 1.19.1 (owner, 2026-10-28: "lets work on releasing 1.19.1 (testing starts 1.19.1.1)"). The first patch-numbered production release: until now each one was 1.<minor>.0. Built from the testing push that set VERSION to 1.19.1 (this changelog is part of that commit), published as the prerelease test-1.19.1.1, which the owner checks with docs/RELEASE_CHECKLIST.md before tagging.
  • Testing builds are now <VERSION>.<N> (tag test-<VERSION>.<N>), N counting that VERSION's testing builds from 1, instead of <VERSION>.testing.<run number>. Why: the owner's numbering ("testing starts 1.19.1.1"); a short number that says which build of the line it is, where the run number (342, 392, ...) counted every CI run on every branch. How: the meta job lists the releases and takes one more than the newest test-<VERSION>.<N> (1 when there is none), and passes the suffix to the Windows, Linux and macOS builds as its suffix output (they now wait for meta, which finishes long before the light bake they already waited for). Testing runs never overlap (the workflow's concurrency group), so two can't take the same N; a run that publishes nothing leaves its N to the next one, and a re-run of failed jobs keeps meta's outputs. Still dotted, never -: 1.19.1.3 splits as the core {1, 19, 1, 3} in every reader (the launcher's versionCmp, pick_release.py, Quarkology's version_key), above 1.19.1 and every 1.19.0.testing.N, below 1.19.2 / 1.20.0. Installed launchers already read it that way (checked: the launcher branch's releases.cpp splits every dotted part), and isTestingVersion (game, Worker) already counted anything past x.y.z as testing.
  • What else reads it: CMake's version check, pick_release.py's and autoupdate.sh's tag and suffix checks, the Worker's /bench version check, the benchmark's channel and build kind (now isTestingVersion, not a search for .testing.), tools/build_release.ps1 and Quarkology's builds.py / util.py / derive.py; the launcher tests and the Worker tests gained cases. The Discord bot's /changelog reads 1.19.1.N too (discord-bot branch).
  • Testing servers, once: an updater from before this change only knows test-X.Y.Z.testing.N tags, so it never sees a test-X.Y.Z.N build: it keeps its last build, and stops the testing server once production 1.19.1 is out (a testing updater never builds production). Run sudo sh tools/server/install.sh once on each box from a testing or v1.19.1 checkout (docs/OPERATIONS.md), as for the 1.19.0 switch.
  • And never again (owner, 2026-10-28: "from now on, the server should automatically update itself once install.sh is run a single time"). Why it was needed twice: a testing updater runs autoupdate.sh and pick_release.py from its own checkout, the last testing build it installed, and stopped (without installing anything) whenever production was newest; a stricter tag pattern in that old code could never learn a new one. Now (a) pick_release.py and autoupdate.sh take any test-<x.y.z><suffix> tag ([.-]word parts) and any such suffix on the commit's VERSION, trusting the signed manifest's channel, tag, version and commit as before, so a later numbering doesn't need new updater code; (b) when production is newer than every testing build, the testing updater installs that production release too (verified and built as a production updater does; its server stays stopped, $STATE/active gone), so its checkout, and with it the updater, is never older than production. Tested with a local release list (QUARK_RELEASE_API=file://...): an installed 1.19.0.testing.392 with production v1.19.1 out stops the testing server and goes on to install v1.19.1; a later test-1.19.1.2 (beside a made-up test-1.19.1-rc.7) starts it again. The one thing still needing install.sh is a change to the systemd units it writes.

Human-made assets

Human-made assets: 16.1% (100 of 618: 31 by the team, 69 third-party) · AI-made 83.9%

Human-made by category: sounds 90/165 · voices 0/245 · models 0/38 · maps 0/21 · fonts 9/9 · images 1/25 · ui 0/78 · bots 0/23 · gamemodes 0/14. Every sound, voice line, model, map, font, picture, UI page, bot area and game mode counts as one item (assets/provenance/). Down from 16.3% in 1.19.0: 8 new AI-made player models.

Release numbering in 1.19.0's testing line (owner, 2026-10-24: "next testing version should be 1.19.0.testing.x")

  • CI labels a testing push <VERSION>.testing.<run number> (QUARK_VERSION_SUFFIX .testing.N in .github/workflows/release.yml), tagged test-<VERSION>.testing.<run number>, and VERSION stays at the production release the build follows. Before, a testing build was <VERSION>-test.<N> and every push bumped VERSION with its own changelogs. Why the dotted form and not -test.N on an unchanged VERSION: -x is a semver prerelease, older than the release (1.19.0-test.400 < 1.19.0), so a testing build after the release would sort below it. 1.19.0.testing.5 has no -: the launcher's versionCmp (src/launcher/releases.cpp) splits it as the core {1, 19, 0, 0, 5} (a word reads as 0), newer than 1.19.0 and older than 1.19.1 / 1.20.0, and newer than every old 1.19.0-test.N. Installed launchers are built from the launcher branch, whose releases.cpp is the same code (checked), so they need no change. The tag still never starts with v<digit> or a digit (launchers 0.12 - 1.16 would install it).
  • Changelogs for testing pushes: no bump, so a push adds its notes to changelogs/next.md (short) and changelogs/full/next.md (full); the testing prerelease shows next.md (or, while VERSION names a release not out yet, changelogs/<VERSION>.md). The release PR bumps VERSION and renames both to <VERSION>.md. A production tag fails while a next.md is left, and a testing push warns when VERSION is unreleased and next.md is still there. The meta step no longer warns when VERSION equals production (that's the normal case now); it still warns when VERSION is older than production.
  • Testing detection (isTestingVersion: a testing build never rates ranked, and its servers default to the testing channel) now also matches a version with more than three dotted parts, in the game (src/game/governor.cpp) and the ranking Worker (tools/master/worker.js). Without it a 1.19.0.testing.N build would have counted as production (the old test was "has a -").
  • Worker version length: /announce, /list and /racerun kept 16 characters of the version; 1.19.0.testing.341 is 18, so two testing runs sharing the first 16 characters would have listed each other's servers. Now 32. (The Worker deploys with the next production release; until then testing runs that share 16 characters list each other's servers, and protocol checks still refuse a mismatch.)
  • Testing server updater (tools/server/autoupdate.sh, pick_release.py): reads both tag forms and compares like the launcher. Because a testing build now sorts above the production release it was merged into, the testing server also stops when the newest production tag's commit contains the newest testing build's commit (git merge-base --is-ancestor, checked once per pair of tags), so it doesn't keep running the release candidate next to the identical production release. One-time step for existing boxes: the updater runs from the testing checkout, which stays on the last testing build it built, so the old updater never sees a .testing. tag; re-run install.sh once from v1.19.0 or newer (docs/OPERATIONS.md).
  • CMake accepts x.y.z.testing.N (and still x.y.z-suffix); the Windows resources flag it prerelease with N as FILEVERSION's fourth part.
  • Tools that parse testing tags or versions read both forms: the editor dashboard, the Quarkology app (then called Quark Project; qo/util.py version keys sort .testing.N above its release, qo/play.py compares like the launcher), the docs manager's release links, the GitHub Manager's version pages (and its seed rule for new installs), the sound editor's installer, and tools/build_release.ps1 (local testing builds take the tag's .testing.N).
  • Tests: launcher versionCmp / versionFromTag / shouldAutoInstall / testing-channel order across old and new tags (tests/launcher_tests.cpp), isTestingVersion (tests/governor_tests.h), the Worker (testing detection, /list keeping two runs apart), and the tools' own tests.

Grapple launcher on the left arm (owner, 2026-10-25: "there should be a grapple hook model in the left arm and the grapple comes out of it")

  • Two new props (src/client/models.*, at the end of the prop list so older override indexes keep their numbers): HOOK_ARM (the left forearm in the arms' dark colour, a steel housing with a team-coloured stripe, a cable drum on the outer side and a barrel on top) and HOOK (the claw: shank, head, three splayed prongs). One constant, kHookMuzzle (0, 0.07, -0.66 in the arm's space), is both the model's muzzle and where every rope starts, so a rope can't drift off the barrel; kHookSeat is how deep the claw sits in it. The steel is fixed rather than the map's metal so it reads on every map; only the stripe follows the team colour. 10 boxes per player, drawn from the existing reused parts list: no per-frame allocation.
  • Third person: the arm hangs from the left shoulder and follows the aim (normal and hide and seek / tag). Not hooked: the claw sits in the barrel. Hooked: the claw sits at the hook point, turned along the rope. Other players' ropes start at their muzzle as drawn this frame; before, they started at the body's centre (1.2 m up), which looked like the rope came out of the chest.
  • First person: the launcher sits low on the left, mirroring the weapon's HUD position, at 70% of the viewmodel size. Hidden with the viewmodel (HUD hide), while scoped, while dead (spectating shows the watched player's), and when the mode has no grapple (moveTuning().grapple, the rule the netcode uses, or the run-only modifier). It doesn't depend on being "it", so hide and seek runners have it. While hooked the claw leaves the barrel and the arm eases up and turns toward the hook (at most about 30 degrees). Your rope now starts at this muzzle instead of a fixed point left of the eye (the old start is kept when the viewmodel is hidden).
  • Renderer: viewBoxXf / viewMeshXf draw view boxes / meshes turned in camera space (the raised arm).
  • Overrides: owner mesh and box-model overrides (docs/ASSETS.md) work for both props in both views; an override's barrel has to stay at kHookMuzzle.
  • --hook (debug, screenshots): holds the grapple button; with --models the lineup turns side-on and every other player hangs on a rope.
  • Assets: 32 models exported (was 30); models/prop/HOOK_ARM.gltf and HOOK.gltf registered as AI-made (box models). The model editor (tools/model_editor/) knows both props and shows the arm on its lineup players. Tests: tests/assets_tests.cpp counts (8 props, 32 models); RELEASE_CHECKLIST has a row for the arm.

Shots and explosions (q, 2026-10-25: "are there even weapon sparks for wall/player interactions? if not add it, also muzzle flash"; "explosions should be client side ... the only thing not client sided should be player deaths")

  • Wall sparks (localImpactSparks, game.cpp): your hitscan shots already sparked on an enemy you hit (X4); a ray that ends on a wall now leaves a small yellow-white spark there (0.2 s, against 0.35 s for the red player spark), plus one where it passes through a wallbang. Shotgun ricochet legs spark at each bounce. Why: there was no feedback at all for a miss, so you couldn't see where your shots landed.
  • Other players' shots spark too (shotSpark): their shotgun pellets, rifle / sniper shots and laser (at 20 Hz, like yours) spark on the first player in the way (not the shooter, their teammates or you), else on the wall. Purely cosmetic: hits are still the server's. Cost: one ray per pellet against the few player boxes, which were already traced for the tracers.
  • Muzzle flash (drawMuzzleFlash): two see-through boxes (a hot core in a fainter orange glow) at your weapon model's muzzle for 50 ms a shot (70 ms for the sniper), and at the end of everyone else's gun arm on each firing edge. The laser's is small and in its beam's colour, lit while it fires. None for knife, syringe or locator, none on yours while scoped or with the weapon model hidden. 2-4 extra translucent boxes while one shows, nothing otherwise.
  • Settings > GAMEPLAY > SHOTS: IMPACT SPARKS and MUZZLE FLASH toggles (impact_sparks, muzzle_flash in quark.cfg; both on by default). IMPACT SPARKS off also turns off X4's enemy-hit spark. OTHER PLAYERS' TRACERS / BEAMS now only hides their tracers; their sparks follow IMPACT SPARKS.
  • Client-side explosions (localBoom): your rocket or bolt (the predicted copy before the server's arrives, then the server's copy drawn ahead by X5's lead) explodes / hits on your screen the frame its path meets a wall or an enemy as you see them, with the explosion and its sound. The sticky launcher's detonate (alt-fire) sets off your armed stickies at once (armed: seen for the arm time, so never one the server would still hold back). The server's own explosion of that projectile is then skipped when it arrives, if it's within 4 m of yours; if it blew up somewhere else (the enemy moved), that one shows too, so you never miss a real explosion. Damage, knockback and deaths stay the server's. Why: the explosion was a round trip plus the interpolation delay late (about 60-120 ms online), the last effect of your own shot still waiting on the server.

HUD editor tab in the Quarkology app (owner, 2026-10-25: "create an editor tab for the HUD in my quark project program")

  • New tool tools/hud_editor/ (Python standard library server on 127.0.0.1:8774 + one page), shown as Editor Manager > HUD Editor in the Quarkology app (installs.json installs it into Quark\hud-editor\ on the next open; shell/tabs.default.json / pm/tabs.py add the tab, kept alive like the other server editors; the Editor Manager lists it and now counts src/client/hudwidgets.* as the HUD editor's, not the UI editor's).
  • What it edits, on a 1280x720 preview in the game's 720p units (so it holds at every resolution): the built-in HUD elements in quark.cfg (hud_<N>: position, scale, own colour, hidden, and for the weapon model depth and tilt), including the grapple launcher's own hud_7 (independent of the weapon model, with pitch / yaw / roll, as in PR #81); the hud.cfg widgets (value, style, anchor, position, size, bar size, max, icon, colours, label; dragging re-anchors to the nearest corner like the in-game editor); the huds/ profiles (open / save); and hud.cfg as text (copy for the game's IMPORT). Drag, wheel to size, arrows to nudge, undo / redo, Ctrl+S.
  • Why it writes only some of quark.cfg: the game owns that file and rewrites it when its settings change, so a save replaces just the hud_*, hud_widgets and hud_verbose lines in place (line endings kept) and the page warns when quark.exe is running (those lines are read at game start). hud.cfg is re-read by the game within a second, so widget edits show in a running game. Each replaced file is kept once per run as .bak; writes are atomic and need the page's X-Quark-Editor header (Host checked), like the other editors.
  • Trade-off: the element shapes are redrawn in the page from Game::drawHud*'s numbers rather than by the game, so they're close, not pixel-exact (fonts differ); the weapon and launcher are outlines of where the models sit. Settings > HUD in game stays the reference.

Weapon model opacity (q, 2026-10-25: "add ability to change viewmodel opacity")

  • GAMEPLAY > VIEW > WEAPON MODEL OPACITY (viewmodel_opacity, 0-1, default 1): your first-person gun and the grapple launcher, everything in the renderer's view pass. Between 0 and 1 it draws the view model's depth first (no colour), then its colour blended over only its nearest faces, so it fades as one solid object instead of showing its inner boxes through each other (uFade in the dynamic and flat shaders; Renderer::viewOpacity, reset every frame so the editor's handles stay solid). Cost: the view model's few hundred triangles drawn twice, only while it's see-through; at 1 nothing changes, and at 0 the view pass is skipped (cheaper than now). Why a renderer setting and not a HUD element field: it covers the gun and the launcher at once without touching their placement code.

Screenshot key (bug)

  • F12 screenshots overwrote each other across launches: the name was screenshot_<n>.png with n a static counter from 0, written in the working directory (wherever the game was started from), so the first screenshot of every session replaced the first of the last one, and the notice didn't say where it went. Now screenshots/<YYYY-MM-DD_HH-MM-SS>.png in the user data folder (next to demos/; _2, _3... for more than one in a second), and the notice names the file (or says it couldn't save). Found by the settings audit (item 167).

Gun and grapple placement: own GRAPPLE MODEL element, corner defaults, 6-axis tilt (owner, 2026-10-25: "yes" to its own HUD setting; "the default position of gun/grapple should be further back in the corners"; "add tilt options to gun/grapple be able to change all 6 axis")

  • HUD_GRAPPLE = 7 (after HUD_KEYS, so saved layouts keep their indices), "GRAPPLE MODEL" in the HUD tab after WEAPON MODEL: show, x / y, size, depth, tilt, and a custom colour for the launcher's team stripe. It no longer follows the weapon model's placement, size or hide. Size 1 keeps the old look (the off hand's 0.7 is now kHookViewScale). RESET ELEMENT / RESET WHOLE HUD reset it. Hidden launcher: the rope starts low left of the eye as before.
  • Config format: every hud_<i>= line is now x,y,scale,custom,r,g,b,hidden,z,pitch,yaw,roll (tilt in degrees, appended; older lines read the missing angles as 0). The external HUD editor thread was told the format.
  • Corner defaults: weapon pivot kWeaponBase (0.33, -0.28, -0.66), was (0.2, -0.17, -0.55); launcher kHookBase (-0.33, -0.295, -0.68), was (-0.2, -0.19, -0.58) (camera space, metres). Offsets stay relative to these, so customised layouts move by the same amount. A viewAnchor keeps both models at the same place and size relative to the screen at any aspect ratio and FOV. Before, ultrawide drew them larger and nearer the centre and 4:3 smaller. Trade-off: changing FOV, or zooming without the scope overlay, no longer resizes the gun. Checked at 1280x720, 2560x1440, 1024x768 and 3440x1440 with rocket, sniper (longest), knife, rifle and shotgun.
  • Tilt: PITCH / YAW / ROLL, -90..90 degrees, for both models. The gun turns about its pivot, the launcher about its housing (inside its turn toward the hook). With every angle 0 the old drawing path runs, so there's no extra cost.
  • Muzzles follow: own shots, tracers, the laser beam, the knife slash and projectile starts all come from one weaponFrame (used by muzzlePoint), and the rope from the launcher's frame, so they leave the moved, tilted barrels.
  • --settingstab hud/viewmodel / hud/grapple (screenshots). RELEASE_CHECKLIST: HUD and launcher rows updated, plus a row for the corner placement at 16:9, 4:3 and ultrawide.

Weapon models (q, 2026-10-26: "create some new weapon models; up to 20 boxes per weapon")

  • viewmodelParts (src/client/models.cpp): rifle 17 boxes (was 6), rocket 18 (7), knife 13 (5), sniper 20 (11), laser 19 (8), shotgun 16 (6), sticky 16 (6), bubble 16 (7), syringe 14 (7), crossbow 19 (5); locator unchanged (12). Each keeps its old silhouette, size and pivot so the HUD viewmodel offsets still fit; the new parts are sights, rails, vents, grips, stocks, bands and glowing details on the same colour slots (metal, base, team, glow), so team colours and map tints apply as before.
  • Cost: at most 20 boxes per weapon (the limit is kMaxDefaultParts = 32), one viewmodel drawn per frame, so a few dozen extra triangles. Still AI-made in the provenance registry.

Player and projectile models (q, 2026-10-26: "redo all of the projectiles/player models ... +simple (just the hitbox) +wizard -scout +zombie +fighter")

  • kPlayerModels 5 -> 8: STANDARD, HEAVY, SIMPLE, ROBOT, KNIGHT, WIZARD, ZOMBIE, FIGHTER. SIMPLE takes SCOUT's id 2, so saved configs and servers that named SCOUT show SIMPLE; no protocol change (a model id is already range-checked: an older client shows ids 5-7 as STANDARD).
  • SIMPLE draws exactly the hitbox: the body box (kPlayerHalfWidth wide, up to the head) and the head box (kHeadFraction tall, kHeadWidthFraction wide), for players who want to see what they can hit.
  • The rest are rebuilt with legs, boots, belts, shoulders and face details (13-21 boxes, the limit is 32). Crouching squashes body boxes and keeps head sizes, as before. Cosmetic parts may poke slightly past the hitbox (the wizard's hat, the zombie's reaching arms); hits are unchanged.
  • Projectiles get more parts each (rocket 11, sticky 11, bolt 7, knife 6, syringe 7, bubble 4, orb 3), same sizes and the same flame / core / wobble animation.
  • Provenance: SCOUT removed, SIMPLE / WIZARD / ZOMBIE / FIGHTER added (AI-made).

More player models (q, 2026-10-26: "also add pirate, ninja, bandit")

  • kPlayerModels 8 -> 11 (ids 8-10, appended, so no saved id changes; an older client shows them as STANDARD). Same hitbox; 18-20 boxes each. Provenance: three AI-made entries.

NOGRAPPLE material (q, 2026-10-26: "a material in the editor you cannot grapple to, but can interact with otherwise")

  • Material 10 (nograpple in map files; kMatNoGrapple): solidMaterial() is true for it, so collision, bounds, the grid, bots, sight, hitscan and projectiles treat it like base. Map::raycast reports the hit brush's material; pmove's grapple trace skips the grab when it is nograpple (the hook stops there, so it can't reach a grabbable wall behind it). Shared pmove, so client prediction and the server agree.
  • Map format 2 -> 3 (owner rule): an identity converter step (version 2 files have no nograpple boxes); make_maps.py and the web map editor write version 3. An older game rejects a version 3 map's nograpple line, which is why the version moves.
  • Look: lit like base, slate blue (0.30, 0.33, 0.42) with diagonal stripes so players can tell it apart; same colour in the in-game and web editors.
  • Test: testGrappleAgainstWall also holds the grapple at a nograpple wall for 1.5 s (no grab), walks into it (blocked) and casts a ray at it (stops).

Invisible box faces (bug; q, 2026-10-26: "sometimes on stairs and stuff, certain faces of boxes will be invisible in game")

  • Cause: the world build dropped a face when 81 sample points (a 9 x 9 grid) just off it were all inside some brush. A visible strip narrower than a ninth of the face (a stair side or riser mostly covered by the next step) could fall between the samples, and the whole face was dropped. The test also counted clip boxes, which are invisible, so a face behind a clip wall vanished.
  • Fix (src/client/lightmap.cpp PointGrid::buried): exact coverage. The brushes that hold the face's plane (lifted 1 cm) are clipped to the face, and every cell between their edges is tested once; clip never hides anything. Same cost class (a handful of nearby brushes per face) and run once per map load in the parallel prepass.

Map editor: volcano factory objects (q, 2026-10-26: "a few custom objects ... for a factory inside of a volcano style map")

  • Eight shipped custom objects in tools/map_editor/library_presets/: lava channel, pipe run, catwalk (nograpple deck), conveyor, smelter tank, girder, basalt wall, heat grate. Each repeats along the axes that make sense (x for runs, y for columns, x+z for floor tiles). Tooling only: nothing changes in maps until someone places them.

Sounds: silent mode events and demo shots (sound audit P1s; q approved the fixes)

All three mode sounds are worked out by the client from the states it's already sent (src/client/modecalls.h, pure and tested), so no new event, no protocol change, nothing for demos to convert, and old servers give the new clients the sounds too.

  • Bomb explosion (bombWentOff): the mode state going from PLANTED to EXPLODED in one round plays SND_EXPLOSION in 3D at bombPos, at volume 2.5 (a rocket's is 1.25) and pitch 0.6 (lower and about 2 s long), in the listener's pack. Why not a new sound: the audit's suggestion was "EXPLOSION at 2.0, pitch 0.6"; 2.5 keeps it at 0.25 at 90 m, the 3D cut-off, so it reaches the far side of Depot. The phase isn't checked: the blast can kill the last defender in the same tick, so the snapshot that shows EXPLODED is often already round end. No visual was added (the client still draws no explosion for the bomb; out of this change's scope).
  • Tag pass (tagItOf / tagPassFrom): "it" is PS_HASBOMB, which the server never hides in tag, so a client sees every pass. Live to live in one round, from a player still listed: a pass. "It" leaving (the server draws a new one at random) and a new round's draw are not. The new "it" hears SND_HURT (1.0) plus the sticky-on-you sting (SND_STICKY_ATTACH, pitch 1.6); the tagger hears SND_HIT_HEAVY through the hitsound gate when HITSOUND is on (a tag sends no EV_HIT: damagePlayer returns into rulesStab before the hit feedback); everyone else hears the new "it"'s hurt sound in 3D, in their pack. Before, a tag had only the "X is it" chat line. Arrives a snapshot after the stab, like any tag state; the stab itself was already heard as the knife swing.
  • Race practice checkpoints (PracticeDing): tickRacePractice sends no split (a split's EV_RACE_SPLIT would make older clients log a run), so the client dings itself: at once when your predicted box enters the next checkpoint (PlayerSnap::checkpoint, the server's count), else when the server's count moves on without it (a graze the prediction missed). One ding per checkpoint either way; FINISH on the last, when the server sends you round again.
  • Demo playback, the recorded player's shots (updateSounds): your own shot sounds come from your local input, which a demo has none of, and the other-players loop skipped the recorded player, so a demo's POV gunfire was silent. Now in playback the POV player's PS_FIRING edges play their weapon's sound in 2D at your own shots' levels (0.8, laser 0.6 at 20 Hz), in their pack. Their steps, hurt, jumps and grapple were already played by the own-sound code (from the predicted state and the recorded movement events).
  • Already fixed, checked: your own hurt sound under laser / drain / vampire (1.18.21: once per 0.15 s, louder for more damage, HurtSoundGate); players out of sight and over 20 m now carry their gunfire as EV_GUNFIRE (X9); other players' jump / land / dash / super / hyper (X2).
  • Tests: quark_tests rules modesounds (rules2 half): the rules, then a real Depot fuse (one boom heard), a real Hamlet tag stab (one pass, tagger to victim) and a Viaduct practice lap through all 12 checkpoints from the server's count alone (12 dings, 1 finish).

ACID material (q, 2026-10-27: approved the acid plan; damage 10% of max HP on contact + 10% of max HP a second, q 2026-10-25)

  • Material 11 (acid in map files; kMatAcid; kMapMaterials 11 -> 12): not solid (solidMaterial false), so it is out of the collision grid, the bounds, sight, hitscan and projectiles, like lava and water. Map::rebuild / changeBrush keep a Map::acid box list beside the lava one; Map::inAcid tests the hull against it. Meant as a shallow pool on a solid floor; deeper acid is just a deeper box you wade in (no slowdown: nothing in the plan asked for it).
  • Damage (gamedefs.h kAcidContact 0.10, kAcidPerSec 0.10, kAcidRearmMs 500): counted in pmove after every 1 ms step (acidStep), so it is the same at 1000, 500, 250 and 125 Hz (tested to 1e-4 of max health) and the client's prediction counts exactly what the server will. The touch adds 10% of max health once; every step in it adds 0.1% (10% a second, smooth, no 1 s steps). A new contact needs 500 ms fully out (MoveState::acidOutMs), so a bumpy edge doesn't repeat the 10%. Shares of max health, so 100 HP lasts 9 s and 500 HP too (custom modes, duel lives: lifeMaxHealth()).
  • Netcode (q's rule: damage client-side, deaths server-side): MoveState::acidTaken accumulates the share; the server takes it after each command (Server::takeAcid: health -= share x lifeMaxHealth(), lastDamagedAt = now so regen waits) and zeroes it, so it is never sent. NetClient::selfHealth() is the snapshot's health less pendingAcid() (pred_.acidTaken minus its value at the acked command, x the client's lifeMaxHealth()), so the HUD falls at once instead of a round trip later; it never shows below 1 while the server has you alive. The death is only the server's (killPlayer, cause 250 kDamageAcid, "dissolved"; mode logic constant dissolved).
  • Kill credit: ServerPlayer::acidSince marks when the contact began; a death in acid credits a hit up to kRingOutCredit (3 s) before that (ringOutKiller's window counted back from then), however long the victim then stayed in. Cleared once 500 ms fully out.
  • Phases: no damage in the freeze, round end or match end (acidHurts(), nativeCanDamage's phases; the prediction asks the same). Unlike player damage it does hurt in race: it's the map, like lava. Custom mode logic's can_damage hook isn't asked (the client couldn't predict it).
  • Protocol 44 -> 45: the own movement state carries acidOutMs (u16, flagged by mods bit 64, free: MOVE_* use 1..32) while it is below 500, so a replay after a correction knows whether the next touch is a new contact (2 bytes, only in and near acid). Older demos read as before (kSnapAcidSince; the demo converter rewrites their header). reconcile also compares acidOutMs.
  • Map format 3 -> 4 (owner rule): an identity converter step (upgradeMap3to4; version 3 files have no acid). make_maps.py (prefabs MAP_FORMAT) and the web map editor write version 4. An older game skips the unknown acid line with a warning (it would play the map without the pools), which is why the version moves.
  • Look: unlit like lava (no lightmap texels, no extra pass), a toxic green (top about #5CFF3A, sides darker) with a slow shimmer from a new uTime uniform on the world shader (one glUniform1f per frame; the fragment cost is two sins on acid pixels only). Not added to the light bake as an emitter: lava isn't one either. Same green in the in-game editor and the web editor.
  • Bots: a pool on a floor becomes its own wet nav pieces (NavGraph::Node::wet), cut out of the dry ones and in their own regions; a link onto a wet piece costs 4x (kAcidNavCost), so routes go round a pool unless that is 4x longer, cross it when it is the only way, and leave by the nearest edge. Maps without acid build the same graph as before. The map checks use the same links, so routes through acid still count as reachable.
  • Editors: the in-game editor's M key and MATERIAL cycle (base .. clip, nograpple, acid); its labels list was missing NOGRAPPLE (the cycle showed "?" for it) and is now a static array checked against kMapMaterials. The web editor (tools/map_editor): material lists, notes and colours; quark_maptool's material list (it still listed 10, without nograpple); validate.js counted nograpple as not solid and the view's map bounds skipped it: both fixed.
  • Tests: testAcid (movement group): the contact and per-second amounts at each tick rate, the 500 ms re-contact, the wire (protocol 44 bytes unchanged), the server's health at 100 and 500 max health at 1 ms and 8 ms commands against the client's prediction, the death at 9.0 s (server only, cause 250), the knocker's credit 10 s later, no damage in the freeze, and the bots' wet pieces and a route through a flooded corridor. testMapConverter converts a version 3 file and parses / writes acid; testIncrementalEdits checks the acid list after random edits.

Hit boxes: the body ends at the head's bottom (q, 2026-10-27: "did the head hitbox get fixed to be just the head? with no body hitbox above the bottom of the head hitbox?")

  • What changed: a hit tested one full-height box (the hull, 0.8 x 1.8 m standing) and then asked whether the ray also crossed the head box (headBox: the top 20%, 45% wide, centred, 0.36 m). Anything in the hull outside the head was a body hit, so the 0.22 m each side of the head above the shoulder line (and the corners above the shoulders) were body hits. Now hits test two boxes: bodyBox (full width, from the feet to the head box's bottom, 1.44 m standing) and headBox as before. A ray beside the head above that line misses. The hull itself is unchanged for movement, ladders, zones, lava / acid and spawns.
  • One function (rayHitbox, pmove.h, inline): the entry into the nearer of the two boxes, and whether that was the head. Every hit uses it: hitscan (rifle, sniper and its collateral, laser and the pull beam, shotgun pellets, the knife swing with its 0.04 m grow on both boxes), shotgun ricochets, projectiles vs players (rocket, sticky, crossbow bolt, thrown knife, thrown syringe, bubble: all through stepProjectile), the grapple catching a player (pmove, so the server and the client's prediction agree), and on the client the impact sparks on enemies (yours and other players') and your projectiles' early explosions (enemyOnSegment). Lag compensation is unchanged: the rewound box is split the same way.
  • A headshot is now the head being hit first: before, any ray that also crossed the head box was a headshot, so a shot from below into the chest that would have gone on through the head counted as one. Now the body stops it (a tie, only possible exactly on the seam, goes to the body). Level shots and shots from above are unchanged.
  • Blasts (explode): splash distance is to the nearer of the two boxes (hitboxClosest), so a blast in the gap over a shoulder beside the head is up to about 0.2 m from the player (the head's side or the shoulder's top) instead of 0. The cover rays are unchanged.
  • Stuck props and stickies on a player now sit inside the box they hit (head or body), not anywhere in the hull: a bolt in the head no longer floats beside it.
  • Crouching and sizes: both boxes are fractions of the hull, so crouched (head 0.96-1.2 m), duel double size and tiny scale with it. The SIMPLE player model already drew exactly these two boxes standing.
  • Bots aim lower (aimHeight, botbrain.h and tools/bots/default.qbrain: 1.0 / 1.1 / 1.2 / 1.3 m -> 0.8 / 0.88 / 0.96 / 1.04 m, beginner to expert): the same place on the body, scaled to its new top (1.44 / 1.8). At 1.3 m the expert aimed 0.14 m under the head's bottom, so its pitch noise sent many shots into the gap that is now empty: in the bot aim test (rifle vs a strafer) the expert fell from 12.9 to 9.9 dps (39% -> 30% of perfect aim, under the test's 35% floor). With the scaled aim: 12.9 again; beginner 1.9 -> 1.7, intermediate 4.5 -> 3.9, advanced 7.7 -> 7.2 (the target really is smaller). Their sight checks (the chest and head points on the centre line) were already inside the body and the head.
  • Cost: two slab tests per player per ray, where it was one (plus one head test for the player hit when the weapon has headshots). No allocations; the helpers are inline because hitscan runs them for every player and pellet.
  • Protocol 45 -> 46: no wire change (46's bytes are 45's, so demos convert with the existing header step and play), but an older client would predict grapple catches and sparks with the old box beside the head; the protocol check keeps the two apart (the same reason as 44).
  • quark_maptool (wpn_stats): the pellet hit count's body ends at the head's bottom too.
  • Tests: testHeadBox (rules group head): the two boxes standing, crouched, giant crouched and tiny (seam, widths); rays through the head, beside it at head height and at the top corner (misses), just under the head's bottom beside it and under the chin (body), straight down past the head (the shoulder), up from below (body first), the knife's grow, blast distances, the grapple's ray in pmove beside the head (flies on to the wall). In play: rifle shots at the head, beside it and just under it with their hit events, the client's ray against the player as it sees them giving the server's answer each time, and crossbow bolts the same way (75 / 0 / 50).
  • Not done: narrower legs (settings-gaps item 245 mentions it; q didn't ask for it this time).

Quark Project is now Quarkology (owner, 2026-10-27: approved the rename plan)

  • The app's name everywhere: window title, app name and taskbar id (Quark.Quarkology), dialogs, the tab bar's "updated" banner, the desktop shortcut ("Quarkology"), the Electron package (quarkology), the install folder Quark\quarkology\, the data folder %LOCALAPPDATA%\Quarkology\, the launch scripts (quarkology.cmd, quarkology-hidden.vbs) and the code (tools/project_manager/ became tools/quarkology/, its package pm/ became qo/, moved with git mv). The tab names (Supervisor Board, Claude / GitHub / Editor Manager, the editors) stay. Every other tool's docs, comments and messages that named "the Quark Project app" or "the project manager" now say Quarkology. Why: one name for the app, so a reply, a doc or a folder always means the same thing.
  • Existing installs move by themselves on their next update (q's desktop PC and laptop: Quark\project-manager\, %LOCALAPPDATA%\QuarkProject\, shortcut "Quark Project"), no step by hand and no console window, because the installed old app can only follow its own code: tools/project_manager/ stays for one release as a hand-over the old updater reads. Its installs.json first runs tools\quarkology\setup.ps1 hidden (Quarkology installed, q's files and Electron copied over by server.py --init through the new qo/migrate.py: tabs.json, config, history, Play sessions and notes, checklist ticks, installs-state.json so no tool reinstalls, logs, electron\ and a staged electron.next\ so no 110 MB download; the "Quark Project" shortcut replaced by "Quarkology"), then the old self-update copies the hand-over in, so the old app's usual restart (by itself in the first 5 minutes, else its Restart button) lands on a stub shell\main.js that starts Quarkology and quits. The stub runs the setup itself (with a small "Moving" window) when the first step failed. The old shell's preload.js and tab bar stay in the hand-over unchanged, so the old window keeps working until it restarts.
  • The Chromium profile (sign-ins, the editors' autosaves, window and last tab) is copied by Quarkology's shell on its first start, once the old app has exited (%LOCALAPPDATA%\QuarkProject\ to %LOCALAPPDATA%\Quarkology\, migrated.json), because a live profile cannot be copied: it waits up to 20 s for the old profile's lockfile to go, and if the old app is still open it copies nothing and asks q to close it first (rather than start an empty profile that would then block the copy). Chromium's caches are left out (rebuilt by themselves, most of a profile's size). What the old app wrote after the first copy (a play note saved before the restart, a tab dragged) is copied again by the new server once it holds port 8768, unless Quarkology already changed that file.
  • Nothing is deleted: the old folders stay as they were (plus MOVED.txt), every copy skips files that already exist (so running it twice changes nothing), and an Electron folder is copied into <name>.migrating\ and renamed only when complete (the old app runs from it during the copy). Trade-off: about 250 MB of Electron is on disk twice until the old folder is deleted, by hand or by the release after.
  • Compatibility for one release: the shell also writes shell-status.json into %LOCALAPPDATA%\QuarkProject\ (remote checks reading the old path see Quarkology; new checks should read %LOCALAPPDATA%\Quarkology\shell-status.json), stops a Quark Project Supervisor Board server (...\project-manager\server.py) it finds on 8768 at start, and the Editor Manager (dashboard.py) reads Quark\quarkology\ first and Quark\project-manager\ when the new one has no file (it never reads the hand-over's registry). The editors-only app (tools/editors_app, already "Quarkology Editors") leaves tools/quarkology/ out of its bundle like the old folder.
  • Fixed: Python 3.14's SyntaxWarning for \ in a docstring of qo/updater.py (electron_view).
  • Tests: tools/quarkology/tests/test_migrate.py (an old install with code, q's files, Electron, a staged and a rollback Electron becomes a new one through server.py --init; the old folder unchanged but MOVED.txt; a second run changes nothing; never over an existing file; a locked file is tried again and no marker is written; Electron all or nothing; the late copy after the old app closed and before the server reads; the hand-over registry's order, the files the old self-update needs, its stand-in server), the dashboard's old and new paths.

Tooling: Quarkology and the editors moved to floration/quarkology (q, 2026-10-27: their own private repository, with a wip/ folder synced between the desktop PC and the laptop)

Nothing changes in the game, its build, quark_tests, CI, the website or the release packages. Why: Quarkology and the editors are q's own tooling, on their own schedule and with q's private setup, and work in progress (maps, sounds, models, HUDs) has to move between q's two PCs without being public. The line: whatever the game's build, tests, CI, website or release checklist uses stays here; whatever only Quarkology installs moved.

  • Moved, with their history (git filter-repo of the game's history at b9718ee, 463 commits, the paths renamed from tools/<tool>/ to the top level): tools/quarkology (the app), docs_manager, github_manager, tests_editor, editor_dashboard, sound_editor, ui_customizer, map_editor, bot_editor, model_editor, mode_editor, physics_editor, weapon_editor, hud_editor, editors_app. They are deleted here: nothing in CMake, CI, the tests or the website used them, and the installed copies on q's PCs keep running until Quarkology reinstalls them from the new repository.
  • Stayed: quark_maptool, the editors' native helper, because it links the game's own code (map loader, nav graph, bots, mode logic, movement, weapon sets) and CI builds it on every run: each editor's native/ folder became tools/maptool/<part>/ (map, bot, mode, physics, weapon; git mv), with the physics editor's INTERFACE.md and the weapons editor's DESIGN.md beside their code (src/game/moveexpr.h, weaponset.* and tests/weapon_tests.h point there). CMake globs tools/maptool/*/*.cpp. The bots, modes, maps, the studio and site builders and tools/provenance.py stay too.
  • One-release hand-over: tools/quarkology/ is now a copy of the new app with a two-entry installs.json ("sourcesIn": "game": Quarkology and Electron), and tools/project_manager/ stays. An install from before the move updates itself from it as usual, restarts with the new code (by itself right after opening), and that code clones floration/quarkology into Quark\quarkology-repo\ (hidden, from the game clone's own origin with the name changed so the sign-in that works keeps working, with the PC's git sign-in and then gh's), switches to that clone's installs.json and reinstalls every editor from it once. If the clone fails, a red HALTING item on the Supervisor Board says what to do and the app keeps running and updating from the hand-over copy; the next open or Update now tries again. Both folders go in the next release (docs/HANDOVER.md, "To delete next release").
  • Release checklist: the editors' tool rows and the Quarkology row moved to floration/quarkology's CHECKLIST.md; section 16 here, "Quarkology and the editors", points at it, and the Supervisor Board's Release testing tab shows its rows in that section. The game rows that use an editor's output (a mode with logic, a weapon set, model overrides) stay, and say how to make it. Sign-off is now section 17.
  • Fixed: tools/maptool/physics/test_phys.py checked that the test room is map format version 2; it reads kMapFormatVersion from src/game/map.h now (4), so it no longer fails since the format moved on.
  • Docs: HANDOVER.md (layout table, the move, the delete list), LOCAL_BUILD.md (Quark\quarkology-repo\), PROVENANCE.md, tools/maptool/README.md (new).
  • Tests: the game side runs as before (quark_tests, the maptool tests from their new folders: test_sim.py, test_phys.py, test_modesim.py, test_wpn.py, which checks the weapons editor's catalog only when floration/quarkology is checked out beside this repository). The migration (clone, switch, fallback, gh's sign-in, a folder in the way), the WIP sync (two PCs through one origin: add / change / delete, a file both changed kept twice, deletes against edits, nothing outside wip/ touched, a push race) and the hand-over copy are tested in floration/quarkology (quarkology/tests/test_split.py, test_wipsync.py) against fake origins.

HUD: layout engine, theme.cfg, on-screen editor (q, 2026-10-26: "in the notes it says we talked about creating a custom layout css-like thing, this is what that overhaul should do, and yes do it in game first since it will require code changes, just make sure it is performant")

  • Layout engine (src/client/layout.h/.cpp, lay::Layout): our own small box layout, no library. Rows and columns; fit / fixed / grow / percent sizes with min and max; padding, gap; main-axis start / centre / end / space-between; cross-axis alignment, stretch and align-self; floating boxes anchored to the parent or the screen; clipping with scroll and culling. Fixed capacity (256 boxes), no heap allocation while declaring or solving. Each declaration is hashed as it's made and solve() keeps the last result while the hash is unchanged; same(key) lets a caller skip declaring altogether while the inputs of the declaration (screen size, offsets, scale, measured widths, theme version) are the same bytes as last frame, which is almost every frame. Declarations go into one per-thread scratch shared by every Layout (warm in cache after the first element); a Layout keeps only its results (32 bytes a box). Shared test cases in tests/data/layout_cases.json (11 trees with expected rects), checked by quark_tests layout with the cache, overflow and a zero-allocation check. quark_tests layoutbench: a HUD-sized layout (39 boxes) costs 1.5 us declared and solved, 0.8 us with the cached solve.
  • theme.cfg (src/client/theme.h/.cpp), next to hud.cfg: a CSS-like style sheet for the HUD. Selectors hud (every element), hud-panel (the score box), killfeed, chat and hud.<element> (health, weapon, bars, speed, slots, keys, score, killfeed, chat); a later rule wins for the same selector, hud.<name> over its class, a class over hud. Properties: background, color, opacity, border (and border-width / border-color), radius (border-radius), padding, margin, gap, font-size, text-align, stripe. Colours: #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), a few names, team / accent / enemy (your current team and enemy colours). Every value is clamped. A bad declaration is skipped and a broken file (a brace left open) gives the default theme; the error names the line. The rules are resolved into one style per element when the file changes (the hud.cfg watcher thread, now shared by up to 4 files: src/client/filewatch.*), never per frame; nothing is parsed while you play. Why our own format and engine and not a library: a fixed list of properties on a fixed list of selectors can't break or hide what it doesn't style, and it costs nothing per frame.
  • Ranked: a theme's opacity can't take an element below 30% (kRankedMinOpacity), so a theme can dim the HUD but not hide it. Everything else a theme does is cosmetic and client-side.
  • Renderer::roundRect: rounded rects and rings (borders) as one quad in the existing 2D batch, cut by a distance test in the UI shader (a spare Vertex2D byte flags it, the uv carries the quad's own pixel position): no new draw call, no new state change.
  • The HUD ported onto it: every element (health, weapon name, cooldown bars, speed meter, weapon list, key overlay, and the score box, kill feed and chat, which are now elements hud_8, hud_9, hud_10 after the existing ones so saved layouts keep their numbers) declares its boxes into its own Layout, and the element's resolved style (background, border, radius, padding, margin, gap, font size, opacity, colour, stripe, text alignment) is applied as it's drawn. With no theme.cfg the HUD is pixel-identical to 1.19.0: deterministic screenshots (fixed camera, kill feed and chat samples) at 1280x720, 1920x1080, 1024x768 (4:3) and 2560x1080 (21:9), and with chat open: 0 pixels differ.
  • Performance (the ZONESTATS build's drawHud zone, --bench 20 on bastion at 1280x720 with the kill feed and chat filled, 12 alternating runs of each build on the same machine, medians; software GL, so the frame rate is the renderer's): the HUD before the port (c7c1505) 29.9 us a frame, after 29.1 us; its worst frame 181 us before, 171 us after (the largest worst frame over the 12 runs 1222 us before, 239 us after); called 40 times in a row (warm caches) 9.8 us a call before, 7.3 us after. Frame rate unchanged within the noise: avg 63.0 / 62.8 fps, 1% low 47.1 / 47.0, 0.1% low 37.5 / 36.1 (that one spreads 30-45 before and 24-42 after between runs). Allocations in drawHud: about 0.06 a frame (from a few a frame before: the score box's warmup line was built in a std::string every frame). How: the first port cost about 6 us more a frame, mostly cold memory (each element's layout touched a dozen separate arrays after the world pass had flushed the caches); won back by one shared declaration scratch, the keyed skip (Layout::same), prefetching every element's layout and style in two passes at the start of drawHud, and caching text widths (kill feed and chat lines on the line, captions, the score box's lines) and the weapon list's labels (formatted and measured only when a label's seconds change: no weapon table lookup or snprintf a frame); theme.cfg's once-a-second check uses the frame's clock (no clock read a frame). Renderer::text grows the 2D batch once per string instead of per glyph, the batch no longer zero-fills vertices it is about to write, and the 2D batch is now indexed quads: 4 vertices a glyph or rect instead of 6 (a third less to write and upload for all of the UI, menus included; one glDrawElements over a fixed index buffer; pixel-identical).
  • On-screen editor (src/client/hudeditor.cpp; Settings > HUD > EDIT ON SCREEN, screen path settings/hud/edit): every 2D element drawn where it goes with sample values (score box, kill feed and chat included), each with a thin outline (dashed when hidden). Click selects; drag moves, snapping (within 6 px) the box's edges and centre to the screen's edges and centre and to the other elements' edges and centres, with pink guide lines (Alt: no snapping); a corner handle or the wheel (x1.05 a notch) or + / - (0.05 a press) sizes; arrows nudge 1 unit (Shift: 10); Tab / Shift+Tab pick the next / previous element; Delete hides / shows. The inspector (320 px wide; on the right, or the left while the selection is under it) has ELEMENT, SHOW, RESET ELEMENT, X, Y, SIZE, OWN COLOR / COLOR (quark.cfg hud_N) and the element's style (theme.cfg rule hud.<name>): OPACITY, BACKGROUND and BG OPACITY, BORDER and its colour, CORNERS, TEXT SIZE, PAD ACROSS / DOWN, GAP and TEXT ALIGN where the element has several parts or lines, RESET STYLE; then UNDO / REDO, RESET ALL, CLEAR THEME, WIDGETS (the hud.cfg widget editor) and DONE. Every change is an undo step (64 kept; a drag, a slider's whole drag or a colour picker's session is one step), Ctrl+Z / Ctrl+Y (Ctrl+Shift+Z). theme.cfg is saved after each finished change (as text from the resolved rules: comments in a hand-written theme.cfg are not kept once the editor saves it). Cost while closed: one bool check (hudEdit_) in drawSettings, onKey and onScroll; nothing else runs.
  • Settings > HUD footer (bug): BACK and RESET WHOLE HUD sat at the panel's usual footer, y 640 of 720, right over the weapon list and the health box under the see-through panel. On the HUD tab the panel now ends at 608 and the footer sits at 560, clear of the HUD's bottom row at every aspect ratio. The other tabs are unchanged.
  • UI test: settings/hud/edit is explored like any screen: the elements are handles (dragged: they move), the selection's corner a size handle, the keys key actions; new observables hudedsel, theme, hudedit; the reset between scenarios clears the theme.
  • tools/hud_editor: draws the score box, kill feed and chat (elements 8, 9, 10) with sample lines, so its preview matches the game's element list. quark.cfg's format is unchanged (hud_8..hud_10 are new lines of the same kind); theme.cfg isn't edited there yet.

Rocket riding (q, 2026-10-27: "i want to be able to grapple to projectiles like rocket/knife/syringe", then "it would just be rocket")

  • What it does: a grapple fired along a rocket in flight catches it (anyone's rocket, your own included: fire, hook, ride) and the anchor flies with the rocket; you're pulled with the normal grapple pull (38 m/s2 up to 24 m/s, momentum kept; the rocket's flight is unchanged). It lets go when the rocket explodes or is gone (its 5 s life), at the usual latch limits (kGrappleLatchMax 3 s, the grapple's range, line of sight from your body to the rocket), when the rules stop allowing it, or when you let go of the button; your momentum is kept and the normal hook cooldown starts. Knives, syringes, stickies, bolts and bubbles are never candidates: the hook goes past them as before.
  • Catch box: 0.8 m cube round the rocket (kRocketCatchHalf 0.4, pmove.h). The rocket model is 0.6 m long and 0.3 m over its fins, too small to hook reliably at 48 m/s: it moves 0.8 m in one 60 Hz frame, so a box one frame's flight wide (and a player's body width) catches a hook aimed at the drawn rocket in any frame, and covers the few ms the client's idea of the server's rocket can be out (snapshot jitter, the 1/64 m position grid). Tested with one slab test per rocket (rayAABB), after the player boxes, nearest wins as before.
  • A rocket you just fired (inside the 1.2 m release distance) isn't caught and let go at once (that started a 1 s cooldown): the hook waits for it, and a held grapple catches it a few ms later instead of going past it onto the wall behind.
  • Where it's tested (Server::grappleTargets, the client's NetClient::hookProbe the same way): your own rockets where the server has them as it runs the command (your client draws them that far ahead already, X5), anyone else's rewound along their exact path (projectileRewind, the inverse of projectileExtrapolate's 1 ms step sum) to where you saw them: your view time plus rocketSeenMs (the fixed interp delay you report, else two snapshots: what the client extrapolates projectiles by, less its jitter margin). Both sides do the same sums, so the predicted catch is the server's.
  • The anchor follows by itself: MoveState gains hookProj (the rocket's id), hookVel and hookGrav; while riding, pmove flies the anchor one projectile step per 1 ms step with Server::stepProjectile's own arithmetic, and lets go in the step where that path meets a wall (where the rocket explodes). The server sets the three from the real rocket each tick after the projectiles fly (tickRocketRides), which then agree; a rocket that hits a player is the server's word (a snapshot later). In the riding test the predicted position matched the server's to 0.0000 m at the same command, and the release on the wall came at the same command (cooldown 0.98 s on both).
  • Your own rocket before the server sends it (the game's predicted copy, X5): the game hands its copies to the prediction each tick the grapple is held (NetClient::setRocketCopies), caught as id 0; the next snapshot names it.
  • Performance: nothing is allocated. Candidates are gathered only for a command that can fire the hook (held, not latched, cooldown about over), at most 6 rockets in reach (HookProbe::kRockets), one box test each per 1 ms step. Riding costs one short raycast per 1 ms step (the rocket's 1 ms step, about 5 cm) on the server and in prediction, only while riding; the server's per-tick check is a scan of the 128 projectile slots per rider.
  • Bots ignore it: their probes never get rockets.
  • Custom modes: GENERAL > ABILITIES > ROCKET RIDE (on by default; rocketride=0 in the .qmode). The line is written only when it's off, so existing modes' text and logic labels stay the same; ranked and the standard modes use the default (on).
  • Protocol 46 -> 47: the own movement state's hookTarget may be kHookRocket (33), followed by the u16 rocket id, its velocity and gravity (18 bytes, only while riding); PlayerSnap's hooked may be 1 + kHookRocket, followed by the u16 id, so other players' ropes draw to the rocket as it's drawn. Older demos convert with the existing header step (a 46 header never has 33, which reads as nothing, as before); the world part's new value was never written by an older server, so its records read unchanged. Range-checked on read: hooked above 1 + kHookRocket is nothing, a non-finite or out-of-range gravity is 0.
  • Tests: rules group rocketride (in rules): your own rocket caught, predicted before the server's word, ridden (pulled along, the anchor on the rocket, the prediction on the server's path), let go on the wall with the cooldown on the server and predicted; an enemy's rocket caught with the same id predicted; a predicted copy caught as id 0 and taken back when the server has none; a sticky, knife, syringe, bolt and bubble on the same line let the hook through to the wall; ROCKET RIDE off lets it through a rocket too (and the .qmode round trip). Demo test (edit): protocol 46 headers with a player latch and with 33 convert, and a rocket ride round-trips.

Render scale (q, 2026-10-27: approved "add RENDER SCALE")

  • Setting: render_scale (quark.cfg, a whole percent 50-200, default 100, clamped on load) and render_sharpen (default 1), in Settings > VIDEO + AUDIO > IMAGE: RENDER SCALE (a half-width slider, typed values rounded to whole percent) with SHARPEN beside it, under ANTI-ALIASING. BRIGHTNESS and PLAYER SHADOWS now share the first row, so IMAGE keeps its three rows: a fourth would have pushed SOUND into DEVICES with Windows' EXCLUSIVE AUDIO row.
  • Renderer (Renderer::setRenderScale, ensureMsaaTarget, the end of drawScene): the 3D scene renders at the window's size times the scale (rounded; capped to the driver's largest texture / renderbuffer, keeping its shape) into the off-screen scene target that MSAA and SMOOTH already used, and is then drawn to the window; the 2D layer (HUD, text, menus) is drawn after that at the window's resolution, so it stays sharp. With AA off a scaled scene gets a plain texture target (as SMOOTH). How it reaches the window:
    • AA off, no sharpening: glBlitFramebuffer with GL_LINEAR (at 200% each window pixel is the mean of 4: plain supersampling).
    • SMOOTH: the existing FXAA-style pass, its taps now a scene pixel apart (uRcp = 1 / scene size) and its output the window, so smoothing and stretching are one pass.
    • MSAA: a multisampled blit can't change size, so the samples first resolve (same size, GL_NEAREST) into a single-sampled texture at the scene's size (resolveFbo_), which is then stretched as above.
    • SHARPEN below 100% (not with SMOOTH): a contrast-adaptive sharpen (CAS-style, 5-tap cross, sharpness 0.5) that reads the scene with linear filtering at each window pixel's spot, so it stretches in the same pass.
  • 100% costs nothing: with AA off there's still no target (the scene draws straight into the window), and with SMOOTH or MSAA the same passes run as before; screenshots at 100% are pixel-identical to the previous build with AA off, SMOOTH and MSAA 4X. Targets are made only when the AA mode, the scale or the window size changes (ensureMsaaTarget compares what it last asked for, so a target the driver turns down isn't retried every frame); no per-frame allocation.
  • Pixel-sized things: outlines are given in window pixels, so the world and dynamic shaders get uPxScale (scene pixels per window pixel, set only when it changes) and an outline is width x scale scene pixels: the same thickness at 200%; below 100% at least one scene pixel with a one-scene-pixel edge ramp (thinner, at 50% they went grey and shimmered). The world's panel seams likewise stay 1.5 window pixels, at least one scene pixel. The viewmodel pass, sky and translucent pass draw into the same target, so they scale with it. Unchanged on purpose: Renderer::project (nameplates, editor labels) and the 2D layer work in window pixels; screenshots (F12, --screenshot) read the window after the HUD, so they're the window's size at any scale; the editor's view (narrower than the window, beside its panel) scales the same way, and its picking is a CPU ray, not a depth read.
  • F3: the second line ends with the world's render resolution (3D 640X360 at 50% of 720p; the window's size at 100%).
  • Cost (Xvfb + llvmpipe, 1280x720, Bastion, --bench; software GL, so only relative): the stretch from 640x360 took about 1.7 ms of llvmpipe time with the blit and about 3.6 ms with SHARPEN, so the sharpen pass is about 1.9 ms over the blit there. On a GPU this is one fullscreen pass of 5 texture reads at the window's size, an estimated 20-50 us at 1080p (not measured: no GPU here). Frame rate on llvmpipe (noisy, a loaded machine): 50% ~122 fps avg without SHARPEN, 100% ~76, 200% ~24.
  • Tests: tools/renderscale_check.py (ctest renderscale, needs a display; run by tools/release_check.sh) hosts Bastion headless at 50 / 100 / 200% from a fixed camera: each screenshot is 1280x720, the health number's white pixels match 100%'s exactly (the HUD is drawn at the window's resolution, not stretched), the world differs from 100%'s (it is drawn at another resolution), and render_scale is still in quark.cfg after the game wrote it back. The UI test's settings round trip covers the new slider and toggle (render_scale, render_sharpen through quark.cfg). RELEASE_CHECKLIST has a VIDEO row.

Benchmark (q, 2026-10-27: "can you make a benchmark fps button on the main menu, it will run a demo on hamlet, with 32 bots running around shooting eachother in ffa. the result should show a frametime graph, give avg, 0.1%, min, stdevms"; then "a download data option, and ability to give each run a name and they are saved like demos, download data as csv (list of frametimes in us) or png picture of the result screen"; then "automatically upload data to the database, with the user secret ... cpu, gpu, avg, 0.1%, min, stdevms, game version ... also in testing versions"; then "i will make the demo file")

  • The run is q's demo, played through the normal demo playback (maps/benchmark/hamlet.qdemo, 1,386,937 bytes, protocol 47, recorded by q on Hamlet FFA). Why a demo and not a live bot match: the same frames every run, so two runs of one build differ only by the machine (a seeded bot match drifts with timing: bots, spawns and fights land differently, a few percent of run-to-run noise). The cost: it doesn't measure the bot server's simulation, and the demo has to be converted when the protocol moves on: Game::benchDemoPath converts an older one with demo::convert into benchmarks/cache/ once (the shipped file is never changed). It ships inside maps/ (maps/benchmark/, not ignored like the generated *.qmap), so every package path that copies maps (release.yml, make_test_packages.sh, build_windows.ps1) carries it with no packaging change, and findDataPath finds it like the maps. Missing (a stripped build): RUN BENCHMARK reads BENCHMARK DEMO MISSING, greyed out. The camera is the demo's (the recorder's view); the HUD is replaced by a 300 px box with BENCHMARK, the time left in the demo and ESC TO CANCEL.
  • Timing: 3 s of the demo after its world is first drawn are a warm-up (the world uploaded, caches warm), then every frame's dt (the same frame time F3 uses) is stored until the demo's last record has played (NetClient::playbackDone), or --benchmark's seconds. Recording a frame is one float store into bench_.times, a 2^21-float (8 MB) buffer assigned before the demo starts (pages touched then, not mid-run); no allocation, file or network work happens until the run ends. The uploads' answers are only read when no run is going.
  • Stats (src/game/benchmark.h, header-only, shared with quark_tests): avg FPS = frames / sum of frame times; the 1% / 0.1% lows are 1 / the frame time at sorted index floor(n x 0.99) / floor(n x 0.999), exactly as the F3 overlay and --bench take them; min FPS = 1 / the slowest frame; stdev = the population standard deviation of the frame times in ms. nth_element on a reused scratch buffer, no sort. The graph is 464 columns (2 px each at 720p), each its share of the run's mean frame (bar) and its slowest (gold tick; red at the top when over the scale); the scale is the smallest of 5 / 10 / 20 / 40 / 80 / 160 / 320 ms that holds 1.25 x the 0.1% low frame, never under 5 so the 240 fps line stays on it.
  • Saved runs (benchmarks/<date>.qbench, text): # key,value lines (name, label, date, version, map, demo, uploaded, the numbers, setup: lines for resolution, render scale with the 3D size, display, vsync / low latency, FPS limit, AA / shadow map size, CPU, GPU, driver, version, demo) then frame_time_us and a frame time a line in whole microseconds (q's unit). Named by date so they sort; the run's NAME lives inside, so renaming rewrites the file through a temp file and a rename (never half a file). The default name is <label> <map> <date time>: the label is the first non-empty line of FLAGS.txt beside the exe (trimmed, 24 characters; q's flag builds carry one), else V<version>. The result page compares with the run saved just before it.
  • Exports into benchmarks/exports/: CSV is one header line (frame_time_us (label ...; run ...; version ...; demo ...)) then the frame times in whole microseconds; PNG is the result page as drawn that frame (Renderer::screenshot, the F12 path), taken after the frame is drawn so the notice with its path isn't in it. OPEN FOLDER opens the exports folder from a run, benchmarks/ from the list.
  • Uploads (POST /bench on the ranking server, through RankService::request like /link and /lifetime: its own thread, after the run): secret, name, version (with .testing.N), channel, build (FLAGS.txt's label, else ci for a testing build or a launcher install (installed-files.txt beside the exe), else local), CPU brand (cpuid on x86, /proc/cpuinfo or sysctl otherwise), GPU and driver (GL_RENDERER / GL_VERSION), avg / 1% / 0.1% / min FPS, stdev, frames, seconds, resolution, render scale, AA, vsync, FPS limit, demo. No ranked key: nothing is sent. The run file says uploaded,0 until the Worker answers ok (then 1; 2 if it refuses the numbers, not retried); 0s are sent again at the next start and whenever BENCHMARKS opens, 10 at a time. The Worker endpoint deploys only with a production release (deploy-worker job), so testing builds get a 404 and keep retrying until then.
  • Worker (tools/master/worker.js): /bench checks the secret like /lifetime (the per-IP and per-id write buckets apply), takes every text field hex-encoded (printable ASCII, a length cap each), checks the version's shape, clamps the numbers (FPS to 100,000, stdev to 10 s), refuses min > 0.1% low > 1% low, stores at most 30 runs per player a rolling day ("daily limit", retried by the game), and keeps the channel (testing if the version says so). GET /bench: the 25 newest runs (cached 30 s). New table bench_runs in tools/master/migrations/0002_bench_runs.sql (schema.sql updated). Tests in tools/master/test.mjs.
  • Main menu: BENCHMARK is its own full-width row under DISCORD / DEMOS; QUIT moves down a row (y 594 at 720p) and the error line under it.
  • Tests: quark_tests group benchmark checks the stats on arrays with known answers (avg, the percentile indexes, lows, min, stdev, the graph's columns and scale, one frame and none) and records a Hamlet demo with 31 expert bots and plays it to playbackDone. tools/benchmark_check.sh (ctest benchmark, needs a display) makes such a demo (quark_tests benchdemo OUT [s], never committed), runs quark --benchmark on it in a fresh data folder and checks the printed line and the saved file (every header key, a frame time per frame), then the shipped demo for 5 s. The UI test's new benchmark scenario covers the BENCHMARKS page and a run's result page (rename, both exports, delete, open folder, run again); the menus scenario the main menu's BENCHMARK.
  • Numbers (Xvfb + llvmpipe, 1280x720, a shared 4-core box; software GL, so only relative): the first demo q sent (since replaced by the shipped one, not re-measured at q's word) played 44.8 s measured, 2954 frames: avg 65.9, 1% low 41.7, 0.1% low 34.5, min 32.6 fps, stdev 3.99 ms.

Performance: -O3 + LTO, an SSE4.2 baseline and an AVX2 build picked at startup (q, 2026-10-27: "would compiling the game with -O3 make it run faster", "make me a bunch of versions of the game with different compiler flag optimizations", "can we just force better sse versions, and check for avx2, and if true then execute avx2 version?")

  • What changed: Release builds use -O3 (was -O2) and QUARK_LTO is on by default (was off). x86-64 builds (Windows, Linux; not macOS, whose universal build keeps the compiler default) target -march=x86-64-v2 (SSE4.2, POPCNT; was the 2003 SSE2 baseline). A new QUARK_ISA CMake setting (none / v2 / v3) builds the AVX2 client (-march=x86-64-v3); CI builds it next to the normal one and the Windows and Linux packages ship it as quark_avx2(.exe).
  • Startup: quark checks the CPU (__builtin_cpu_supports: AVX, AVX2, BMI1/2, FMA) before anything else and, when quark_avx2 sits next to it, runs that instead: Windows starts it with the same command line and waits (in a job object, so closing quark.exe closes it too; the launcher keeps seeing quark.exe run), Linux execvs it. --no-avx2 / QUARK_NO_AVX2=1 skip it (src/client/isa.cpp). The benchmark's VERSION line names the build (SSE4.2 / AVX2) and its upload's build gets -avx2.
  • Why: q's laptop runs of the Hamlet benchmark (Ryzen 9 5900HX, RTX 3080 laptop, 2560x1440): -O2 (what shipped) 1114 avg / 737 1% / 635 0.1% low; -O2 + LTO 1203 / 818 / 687; -O3 1540 / 1046 / 911; -O3 + LTO 1613 / 1079 / 950; -O3 -march=native 1810 / 1276 / 1140; -O3 + LTO + x86-64-v3 1962 / 1650 / 1518 (stdev 0.167 ms -> 0.038 ms). Correction (2026-10-28): those six runs were made back to back in that order, each faster than the last, and the official CI -O2 build (testing .388) later scored 1953 avg / 1546 0.1% low on the same laptop, the same as the -O3 + LTO + v3 build. So most of that rise was the laptop warming up (boost and GPU clocks), not the flags; the real gain is unmeasured until an interleaved A/B (.388 vs .392, several rounds each). The changes are kept: they cost nothing and the AVX2 path is verified bit-exact. A render scale run (100% 1953 avg, 50% 2308) shows most of the frame is a fixed per-frame cost, not pixels. Clang 23 (llvm-mingw) was tried too: about 1100 avg, behind GCC -O3; dropped. Intel's classic icc is no longer downloadable.
  • The AVX2 crash: the first x86-64-v3 builds crashed at once. MinGW GCC spills 32-byte AVX registers with aligned moves to stack slots Win64 only aligns to 16 bytes; -Wa,-muse-unaligned-vector-move (binutils 2.38+) makes the assembler emit unaligned moves instead (same speed on every AVX2 CPU). With LTO the code is generated at link time, so the flag is on the link too (aligned 32-byte moves in the Windows AVX2 client: 1671 before, 0 after).
  • Online play: both builds simulate bit for bit the same: no fast-math and no FMA contraction (-fno-fast-math -ffp-contract=off, now on the link as well for LTO), so vectorized code doesn't reorder or fuse floating point. quark_tests simhash hashes every tick of seeded FFAs (players' positions, velocities, health, projectiles); tools/isa_check.sh builds the AVX2 quark_tests and compares (identical over 3 seeds), and tools/release_check.sh runs it.
  • Cost: one more client build in CI and about +22 MB in the Windows and Linux packages. LTO makes local builds' link step slower (-DQUARK_LTO=OFF for quick iteration).
  • Also: -DQUARK_TRACY=ON now links on Windows (Tracy's call stacks need dbghelp). bots.cpp and maptool's measure.cpp include <cstdlib> (std::llabs / std::getenv came in by accident through libstdc++).

Shadow size capped to the GPU (antagonist review, render pipeline)

  • A shadow size larger than the GPU's largest texture (GL_MAX_TEXTURE_SIZE) left the shadow framebuffer incomplete, so shadows silently turned off. The renderer now uses the largest size the GPU has instead (src/client/renderer.cpp).

Profiling: GPU zones in Tracy builds (q, 2026-10-27: "cant we use this very deep and advanced tracy data to optimize the game more")

  • A Tracy capture of the benchmark (o3 + LTO + AVX2, 2560x1440, RTX 3080 laptop) showed 223 us of CPU work per 513 us frame and 290 us outside every zone: LOW LATENCY's wait for the GPU. Tracy builds (-DQUARK_TRACY=ON) now time each pass on the GPU too (shadow, world, dynamic, resolve / scale, HUD: TracyGpuZone via src/client/gpuprofile.h, compiled out of every other build), and that wait is its own zone ("low latency: wait for the GPU").