The implementation plan sliced into tasks sized for one sitting each. Work top to bottom within a track; tracks A, C and D are independent of each other and can be worked in parallel or interleaved. Track E went last of the original plan; Track F (campaign UI for joiners, campaign-ui-plan.md) is the current frontier.
Every task ends the same way: run the loopback regression (cd openjk/build && ./openjo_sp.x86_64 +map kejim_post → exit 0, no errors,
no socket), regenerate the patch, and commit immediately. The patch
workflow and its traps are in plan § 0 — read that section before task T0
and believe it.
Patch numbers below start at 0009 and assume tasks land in the listed
order; renumber freely if they don't. One patch per task unless noted.
Legend: each task lists what it needs (dependencies), what to do, and what done looks like (a check you can run, not a feeling).
- Track A (remote client renders): A1–A6 done. M3 (host players + NPCs render on the joiner) = patch 0014, character velocity/lean = 0015; M4 render-stability confirmed by a 10-minute headless soak.
- Track C (distribution): C1 Linux installer, C2 CI
(
.github/workflows/build.yml), C3 winsock (patch 0016), C4 Windows installer (tools/install-coop.ps1), C5 macOS installer (tools/install-coop-macos.sh, logic validated off-Mac). Patch 0007 regenerated soapply-patches.shruns clean. C4 is verified live: patch 0005 linkswsock32into the JK2SP engine (fixing the winsockLNK2019unresolved externals) so it links under MSVC and the CIjk2coop-windowsartifact builds — now shippingSDL2.dllalongside the exe and DLLs.install-coop.ps1autodetectsGameData(Steam registry +libraryfolders.vdf), stages the co-op files additively (retail untouched), installs the VC++ redistributable, and writes host/join.cmdlaunchers with-Uninstall. Confirmed on a real Windows 10 machine: the campaign plays, and co-op works both Windows↔Linux (cross-platform) and between two Windows clients. - Track D (co-op UX): D1
coop_host(0017), D2localserversLAN discovery (0018), D3 in-game Co-op menu (0019 +zz-coop-ui.pk3). Verified headless viauimenu coopMenu+ screenshot. - Track E (four players): E1
sv_maxclients+ E2 cap raise + protocol bump + E3 headless four-player verification (0020, incl. the loopback qport-collision root cause), and E4 — the first human-verified LIVE session: the developer hosted the kejim_post campaign in a real window with three bot joiners, four players in one game ("and it works"). Patch 0021 fixed the two bugs that session surfaced: disconnected joiner slots never left CS_ZOMBIE (SV_CheckTimeoutsonly examined slot 0), and rejected connects sat on a silent loading screen instead of showing the server's message. - Track G (display / resolution): G1 2D aspect correction
(
r_aspectCorrect2D, default on — 0022) so the HUD and menus keep 4:3 proportions instead of stretching, and G2 QHD/4K/ultrawider_modepresets (modes 13–21 — 0023). Verified at 3440×1440 headless: the 2D layer pillarboxes (black side bars, undistorted menu) with the fix on and stretches edge-to-edge with it off. Wide FOV via the engine's existingcg_fovAspectAdjustis documented, not forced. See widescreen.md. - GPLv2
LICENSEat root, per-OS install guides (docs/install-*.md), and README license/trademark sections.
- Track F — campaign UI for joiners (planned, not started): joiners get world + entities but no objectives, mission text, cutscene handling, or verified level transitions. Plan + task breakdown: campaign-ui-plan.md; tasks F1–F5 below.
- C6 — macOS real-hardware verification:
install-coop-macos.shis shellcheck-clean and validated against a mock build tree on Linux; it has not yet been run on a real Mac. - Extended live combat soak: E3's "10-minute four-player firefight" in a live window — the E4 session verified live multi-player campaign play but was not a timed combat soak.
Patch numbers in task parentheticals below diverged from what landed (C3 is 0016 not 0015; A6 needed no patch; D1/D2/D3 are 0017/0018/0019; E1–E3 are 0020; E4 fixes are 0021). The only D3 piece not machine-verified is mouse-clicking the buttons; the verb code paths and the menu/feeder rendering are confirmed.
- T0.1 Build and run what exists.
Needs: retail JK2 on disk, the packages in README § Building.
Do: clone with submodule,
tools/apply-patches.sh, configure + build per README, symlink assets and modules per README § Running, run the loopback regression, then run the two-client test from plan § 0 (remember: wipe/tmp/jk2-client2first). Done: host window plays normally; second client connects, host logsKyle connected, host can see the second Kyle move; second client's own window is black (that is the bug Track A fixes).
Background: plan § Workstream A and cgame-split-investigation.md. Do not reorder A1–A4; each is a prerequisite of the next. A5 is a loop.
-
A1 —
GetCGameAPIexport. (patch 0009) Needs: T0. Do: inopenjk/codeJK2/game/g_main.cpp, besideGetGameAPI(line ~788), add theGetCGameAPIfunction exactly as sketched in plan § A1: assigngi = *import, buildgameinfo_importthe same wayGetGameAPIdoes, callGI_Init. Noglobalswiring, returns void. Done:nm -D openjk/build/codeJK2/game/jospgamex86_64.so | grep GetCGameAPIshows the symbol; loopback regression passes (nothing calls it yet). -
A2 — client-safe import table. (patch 0010, same patch as A3) Needs: A1. Do: in
openjk/code/client/cl_cgame.cpp, addstatic void CL_BuildCGameImport( game_import_t &import ). Opencode/server/sv_game.cpp:898–1049beside it and mirror all 127 assignments in the same order, each into one of three buckets (plan § A2 has the full classification):- pass-through (
Com_Printf,Com_Error,Sys_Milliseconds2,FS_*, memory, cvars, command args) — assign the same function the server assigns; - gamestate-backed — write small statics above the builder:
CL_CG_GetConfigstringcopying fromcl.gameState.stringData + cl.gameState.stringOffsets[index](pattern atcl_cgame.cpp:297),CL_CG_SetConfigstringdoing lookup-only, never allocating (plan § A2 explains why this rule is load-bearing),CL_CG_GetServerinfofromCS_SERVERINFO; - loud stubs for everything server-only — each prints its own name
once (
static qboolean warned) and returns 0/does nothing. Write a tiny macro to stamp these out; there are dozens. Done: compiles. Behaviour is untestable until A3 — land them together.
- pass-through (
-
A3 — load hook. (patch 0010, with A2) Needs: A2. Do: in
CL_InitCGame(cl_cgame.cpp), whencgvm.entryPointis null, load the library and initialise it per the snippet in plan § A3 (Sys_LoadSPGameDll,CL_BuildCGameImport,GetCGameAPI,CL_InitCGameVM); add the matching unload inCL_ShutdownCGame. Add two temporary probes: one in the branch ("dual-load: initialising cgame"), one in the host path proving the branch is skipped there. Done, in order:- loopback regression passes and does not print the dual-load probe (host path untouched — verify by probe, not assumption);
- two-client test: the second client prints the probe and cgame code actually executes. A crash inside cgame code is success for this task — record the backtrace, it is A5's first work item;
- stub names printed by A2's table are captured in the commit message or a notes file — each is a discovered work item.
-
A4 — defuse
CL_GetDefaultState. (patch 0011) Needs: A3 (only so its effect is observable). Do:cl_cgame.cpp:240–258readssv.svEntities[].baseline— server memory — from client code. When no local server runs (!com_sv_running->integer), return a zeroedentityState_tinstead. Done: two-client test behaves no worse than after A3; loopback regression passes. -
A5 —
gentburn-down. (one patch per batch: 0012, 0013, …) Needs: A3, A4. This is a loop, not a task; run it until milestone M4 (plan § Workstream A milestones). Each iteration:- run the second client under gdb; take the first crash or the most obvious wrong visual;
- at that site, guard the server-state read and fall back to
snapshot data —
cent->currentState.*,cg.snap->ps.*, or a configstring.codemp/cgame/shows how MP gets the same value without a gentity. Guard-and-fallback, never delete thegentpath — the host still has real gentities; - rebuild gamecode only (
cmake --build openjk/build --target jospgamex86_64), relaunch, repeat; - commit a batch of related sites once both loopback regression and
the two-client test pass.
Milestone gates to record in the commit messages when crossed:
M1 world renders (probe
re.LoadWorldfires remotely) → M2 weapon + HUD → M3 host player, NPCs, doors render → M4 both players fight the same stormtrooper for 10 minutes, no crash.
-
A6 —
entityStateFieldsaudit. (patch 0014) Needs: nothing (independent of A1–A5, needs only T0); do it whenever the axis-gizmo bug (plan § Workstream A, "known adjacent defect") gets in the way of A5 testing. Do: incode/qcommon/msg.cpp(patch 0006 territory), compare everyentityStateFieldsentry — order and bit width — againstcodemp/qcommon/msg.cppand againstentityState_tas compiled underJK2_MODE. The assertnumFields + 1 == sizeof(*from)/4(62 + 1 == 63) must hold — check it in the Debug build, where asserts exist. Done: dying in a two-client session no longer draws the RGB axis gizmo; both regressions pass.
Background: plan § Workstream C. C1 and C2 need only T0. Keep the two licensing rules in front of you: never redistribute retail files; never modify the retail install (add files only).
-
C1 — Linux installer. (no patch — outer repo only) Needs: T0. Do: write
tools/install-coop.shto the spec in plan § C1: GameData autodetection (two standard Steam paths +libraryfolders.vdfparsing,--gamedataoverride, validate onbase/assets0.pk3), stage~/.local/share/openjo/base/with symlinks, renderer link,jk2coop-host/jk2coop-joinlaunchers in~/.local/bin/(join's--secondwipes/tmp/jk2-client2first), idempotent re-run,--uninstallremoving exactly what it created. Done: on this machine, from a clean~/.local/share/openjo, the script installs;jk2coop-hoststarts a hosting game;jk2coop-join 127.0.0.1:29070 --secondconnects;--uninstallleaves no trace; running it twice in a row changes nothing. -
C2 — CI builds. (no patch) Needs: T0. Do:
.github/workflows/build.yml, Linux only at first: checkout with submodule, apply patches, configure with the JK2SP flags from README, build, uploadopenjo_sp.x86_64,jospgamex86_64.so,rdjosp-vanilla_x86_64.soas artifacts. Add the windows-latest matrix leg disabled or allowed-to-fail with a comment pointing at C3 — it cannot link until winsock lands. Done: green Actions run on push with downloadable Linux artifacts. -
C3 — winsock port of the UDP transport. (patch 0015) Needs: T0. Unblocks the Windows leg of C2. Do: make
code/qcommon/net_ip.cppcompile on both platforms:#ifdef _WIN32halves for headers,WSAStartup/WSACleanup,ioctlsocket FIONBIOfor non-blocking,closesocket,WSAGetLastErrorinNET_ErrorString. Every equivalent lives incodemp/qcommon/net_ip.cpp— copy its spellings, keep our file's structure and behaviour identical. Regenerate patch 0005 or stack a new patch on top, whichever applies cleanly (see plan § 0 for the new-fileadd -Ntrap). Done: Linux build unchanged (regression passes); the Windows CI leg from C2 compiles and links; two-client test on Linux still works. -
C4 — Windows installer. (patch 0005 links
wsock32)tools/install-coop.ps1: locates GameData via the Steam registry key (HKCU:\Software\Valve\Steam→SteamPath) +libraryfolders.vdf, with a-GameDataoverride; stages the engine (openjo_sp.x86_64.exe), renderer DLL, gamecode DLL,SDL2.dlland the Co-op UI overlay into a staging dir (additive; retail assets read viafs_cdpath, never touched); installs the VC++ redistributable; writes host/join.cmdlaunchers;-Uninstallremoves exactly what it created. Done, verified on a real Windows 10 machine: install → the campaign plays; a Linux client joins the Windows host (cross-platform) and two Windows clients play together; uninstall leaves the retail dir intact. -
C5 — macOS installer. (no patch — outer repo only) Needs: T0. Not in the original plan; added alongside C1. Do:
tools/install-coop-macos.sh, the macOS counterpart of C1 with the platform differences handled: data dir under~/Library/Application Support/OpenJO, launchers in~/bin, GameData autodetected under~/Library/Application Support/Steam(libraryfolders.vdf+--gamedataoverride), engine resolved as either anopenjo_sp.appbundle or a plainopenjo_sp.<arch>binary, gamecode/renderer.dylibs named per architecture (x86_64/arm64,JK2_ARCHoverride), same idempotent re-run and non-destructive--uninstall. Kept portable (notac/tail -r). Done: shellcheck clean; logic validated on this Linux box against a mock macOS build tree (both.appand plain-binary forms, autodetect, idempotent re-run with no manifest dupes, uninstall that removes only what it created and preserves a pre-existing~/binfile, retail GameData left intact). Real-Mac run is C6. -
C6 — macOS real-hardware verification. (no patch) Needs: C5, a Mac (Intel or Apple Silicon) with Steam JK2. Do: build the JK2SP targets on the Mac (note whether
MakeApplicationBundlesproduced an.appor a plain binary), runtools/install-coop-macos.sh, thenjk2coop-hostandjk2coop-join 127.0.0.1 --second. Exercise--uninstalland a re-run. File fixes for anything that only reproduces on real hardware (Gatekeeper/quarantine on the dylibs is the likely suspect — note whetherxattr -d com.apple.quarantineor codesigning is needed, and fold the answer into docs/install-macos.md). Done: two-client co-op session on a real Mac; install → play → uninstall leaves no trace; install-macos.md updated with any hardware-only caveats.
Background: plan § Workstream D — read "What already exists" first; the server side of discovery is already in the tree. D1–D2 are pure engine work testable from the console; D3 adds the menu on top. All of D is independent of Track A (a discovered, menu-joined client that renders black is still a passing test).
-
D1 —
coop_hostcommand. (patch 0016) Needs: T0. Do, in two pieces (plan § D1):code/qcommon/net_ip.cpp: addNET_Restart—NET_Shutdown, re-read cvars,NET_OpenIP. KeepNET_Init's semantics unchanged.- Register
coop_host [maxplayers](server side —sv_main.cpp/sv_init.cppis the natural home): setnet_enabled 1, callNET_Restart, print the bound address and port. Port stays within the existing 29070–29079 scan ofNET_OpenIP— do not use an ephemeral port (breaks D2; rationale in plan § D1). Storemaxplayersfor E5 to consume later; accepting and ignoring it is fine for now. Done: start a plain SP game (no flags), typecoop_hostin the console, second machine connects to the printed address. Loopback regression still opens no socket (the command was not run).
-
D2 — LAN discovery. (patch 0017) Needs: D1. Do (plan § D2):
code/server/sv_main.cppSVC_Info(line ~247): addhostname(newsv_hostnamecvar, defaulting to the player name) andgame=jk2coopto the infostring.code/client/cl_main.cpp: addCL_LocalServers_f(commandlocalservers): clear the list, broadcastgetinfo <challenge>to 255.255.255.255 ports 29070–29079, twice, staggered. Reference:codemp/client/cl_main.cppCL_LocalServers_f.- Same file,
CL_ConnectionlessPacket(line ~616): add theinfoResponsebranch — verify challenge,protocol, andgame=jk2coop; record{address-from-packet-source, hostname, mapname, clients, sv_maxclients}into a newcls.localServers[16], deduped by address. Reference:CL_ServerInfoPacketin the same codemp file. Print each newly discovered server to the console (that print is D2's test surface and D3 renders the same array). Done: host on machine 1 viacoop_host; on machine 2localserversprints the host with map name and player count within two seconds;connectto the printed address works. A stock JA server on the LAN (if handy) does not appear.
-
D3 — Co-op menu. (patch 0018 + new committed asset + tool change) Needs: D1, D2. Split across three commits if convenient:
- Overlay pk3 plumbing (outer repo): create
assets/coop-ui/holding original-authorship menu files (write from scratch — plan § D1 licensing note; these are ours and are committed, unlike anything retail). Addtools/build-coop-ui-pk3.shzipping it tozz-coop-ui.pk3; wire installation into C1's installer if it has landed. - uiScript verbs (
code/ui/ui_main.cpp,UI_RunMenuScriptat line ~895):coopHost→Cbuf_AddText("coop_host …"),coopRefresh→localservers,coopJoin→connecton the selected feeder row,coopConnect→connecton theui_coopAddresscvar (register it archived). Add a feeder ID for the server list, backed directly bycls.localServers(the SP UI is in-engine; follow theUI_FeederItemTextpattern in the same file). - The menu page: a Co-op page reachable from the in-game menu:
Host button (max-players selector 2–4 writing the
coop_hostargument), the server-list feeder with a Refresh button, and the direct-connect field + button (this is D3 of the plan, folded in here). Done: full mouse-only session — host loads a map, menu → Co-op → Host; joiner (other machine) menu → Co-op → sees host listed → clicks it → connected. Direct-connect field also works with a typedip:port. Nothing on either command line.
- Overlay pk3 plumbing (outer repo): create
Background: plan § Workstream E. Gate: Track A at milestone M4 (two players fully playable). E1+E2+E3 shipped together as patch 0020 (the plan's "0019 for E1+E2" slot was taken by D3's co-op menu).
-
E1 —
sv_maxclientscvar. (patch 0020) Registeredsv_maxclients(latched, default 2, clamped 1–MAX_CLIENTS) insv_init.cpp;SV_DirectConnect's free-slot loop is now bounded by the cvar (maxConnect) while allocations stayMAX_CLIENTS-sized.SVC_Inforeports the cvar.SV_CoopHost_fsets it from its arg (re-Cvar_Getto apply the latch). Verified headless:sv_maxclients 1refuses a joiner with "Server is full" (connectResponse 0);2accepts one (2ClientEnterWorld, connectResponse 1); loopback boots clean. -
E2 — raise the cap. (
MAX_CLIENTSnow in 0004;PROTOCOL_VERSIONin 0020)#define MAX_CLIENTS 4(q_shared.h:618) andPROTOCOL_VERSION 41(qcommon.h:206) — theCS_LIGHT_STYLES = CS_PLAYERS + MAX_CLIENTSrenumber shifts every later configstring, so a stale build is now rejected at connect on the protocol bump instead of silently desyncing. Consolidation note: E2 originally shipped as a 2→4 re-edit in 0020 on top of 0004's 1→2. The redundant intermediate2was later removed — 0004 now setsMAX_CLIENTSstraight to 4, and 0020 keeps only thePROTOCOL_VERSIONbump and thesv_maxclients/qport work that genuinely layers on the co-op patches.svs.numSnapshotEntities(MAX_CLIENTS * 4 * 64) auto-scales — no manual change. Builds clean; loopback + two-client tests unchanged. -
E3 — four-player verification. (patch 0020) Host + 3 dual-load joiners on
kejim_postunder one Xvfb, each with its own wipedfs_homepath+ gamecode symlink and a distinct name. All four entered the world (0 "Server is full", 0 snapshot-ring warnings, 0 crashes) and all three clients dual-loaded and rendered real 3D frames (ImageMagick mean ≈0.15, ~10k colours — far above the black-screen floor). Patch 0008's spawn ring keeps them clear. Root cause found + fixed en route: multiple same-IP loopback clients all seedednet_qportfromCom_Milliseconds(), which is ≈0 this early in startup, so their qports collided andSV_DirectConnectreconnected joiner 2/3 into joiner 1's slot — only one ever entered. Fix (both in 0020): seednet_qportwith the process id (newSys_GetProcessId(), unix + win32) so same-host clients get distinct qports, and match the reuse loop on qport alone (drop the loopback-hostile|| from.port == remoteAddress.portclause, whichSV_PacketEventnever needed either). This is the project's headline milestone. -
E4 — four-player LIVE session + slot-lifecycle fixes. (patch 0021) Human-verified 2026-07-11: the developer hosted
kejim_postin a real window on the desktop (campaign intro cinematic, objectives, and scripted NPCs all run for the host player) while three driven bot clients joined from a hidden Xvfb — four players in one live game, confirmed working by the player. Two bugs surfaced and fixed en route:SV_CheckTimeoutsonly ever examinedsvs.clients[0](stock SP assumed one client), so a disconnected joiner's slot stayedCS_ZOMBIEforever — the server read "full" minutes after a leaver, and unresponsive joiners were never timed out. Fixed to loop every slot, matching codemp. Verified headless (fill 4/4 → one leaves → new joiner takes the freed slot) and live (the bot crew was swapped mid-session; all three replacement joiners entered the vacated slots).- A connect rejection left the joiner on a silent loading screen:
the client printed the server's OOB
print("Server is full.") to the console but kept resending connect requests forever. Now aprintfrom the dialled server while still connecting is treated as a rejection — the client stops retrying and drops to the menu showing the server's message. Known limitation (next co-op tier, not a bug): campaign UI — cutscenes, objectives, mission text — renders only for the host player; joiners see world + entities. Playing the campaign co-op means the human hosts. That limitation is Track F.
Background: campaign-ui-plan.md — read it first; it holds the problem statement (SP gamecode calls the cgame in-process, "jump the network" is a literal Raven comment), the design principles (host player is canon; configstrings for state, server commands for events; joiners spectate cutscenes), and the code pointers. One patch per task. Every task keeps the solo loopback regression green.
-
F1 — objective sync. Needs: nothing. Do: configstring range for
mission_objectives(packed text/status/display per slot), written when the ICARUS objective verbs fire; joiner cgame mirrors it and the datapad reader (cg_info.cpp:224) uses the mirror whencg_remoteClient, the directgentread otherwise. BumpPROTOCOL_VERSION. Done: joiner's datapad matches the host's, including one objective completed mid-session; late joiner gets the current set; solo datapad unchanged. -
F2 — mission text + centerprint sync. Needs: F1. Do: replace the gamecode's direct
CG_CenterPrintcalls (g_target.cpp:1056et al) with a helper that prints on the host and broadcastscp "<key>"to all clients; handlecpin the joiner cgame's server-command dispatcher. Done: host hits kejim_post's checkpoint trigger → every joiner shows the same centerprint; solo unchanged. -
F3 — cutscene handling (spectate/freeze MVP). Needs: F2. Do: broadcast
cutscene 1|0when the host enters/leaves an ICARUS camera (g_camera.cpp); joiners letterbox + suppress their input for the duration. Briefing videos stay host-only (joiners get a "briefing in progress" card). Done: kejim_post intro cutscene freezes + letterboxes joiners, releases cleanly; no joiner can roam mid-cutscene. -
F4 — level transition follow. DONE (patches 0027 + 0028). Two distinct problems were found and fixed:
- Remote client crashed on transition (patch 0027,
code/client/cl_parse.cpp).CL_ParseGamestatecleared client state but never flushed the renderer, so a joiner receiving a new-map gamestate re-ranRE_LoadWorldMapwhiletr.worldMapLoadedwas still set from the old map — the renderer aborted with "attempted to redundantly load world map" and the joiner dropped. The host avoids this viaCL_MapLoading → CL_FlushMemory. Fix: callCL_FlushMemory()inCL_ParseGamestatewhencls.rendererStarted— this fires only for the remote-client transition (the host already flushed, so itsrendererStartedis false; a first connect has no renderer yet). - Joiner carry-over (patch 0028,
code/server/sv_ccmds.cpp+codeJK2/game/g_client.cpp). StockSV_Player_EndOfLevelSaveonly saved client 0 into the singleplayersave/playerammo/playerinv/playerfplvlcvar set. Now it loops every connected slot into per-slot suffixed cvars (playersave1, …; slot 0 keeps the bare names so retail SP is unchanged), andPlayer_RestoreFromPrevLevelreads its own slot. Joiners carry weapons/health/ammo/force across a transition, gated by the same KEEP_PREV / load-transition condition as the host. (The joiner also already survives a same-map reload / mission-fail respawn via the patch-0004SV_SpawnServerclient-preserve loop.) Done + verified headless (PTY-driven kejim_post → kejim_base): both players re-enter the new map, zero redundant-load errors,playersave1populated.
- Remote client crashed on transition (patch 0027,
-
F5 — verification: harness + live session. Needs: F1–F4. Do: extend the headless harness — scripted host walks kejim_post's opening to the checkpoint + first objective; assert the joiner log shows the
cpbroadcast, the objective configstring, and the F4 map-change reload. Then a live session: human hosts a full level with 3 bots. Done: harness green; a joiner window shows objectives + mission text through a full level, live.
Background: the stock engine caps r_mode at 2048×1536 and draws the whole
2D layer (HUD, menus, masks) through a single hardcoded
glOrtho(0, 640, 480, 0, …) that stretches the 4:3 canvas across the entire
framebuffer. On 16:9 it is visibly stretched; on 21:9/32:9 it is severe. The
3D view has a correct Hor+ FOV path already (cg_fovAspectAdjust), just off by
default. User-facing guide: widescreen.md.
-
G1 — 2D aspect correction (DONE, patch 0022). Add
r_aspectCorrect2D(archived, latched, default1). InRB_SetGL2D(code/rd-vanilla/tr_backend.cpp), when the framebuffer is wider than 4:3, set the viewport/scissor to a centred 4:3 sub-rectangle (pillarbox); when taller, letterbox.0restores the legacy stretch. The 3D view is unaffected (it renders under its own projection at the full viewport). Done: at 3440×1440 the far-left/right HUD columns read black (mean ≈ 0) with the fix on and lit (stretched content) with it off; menu proportions correct. -
G2 — high-res + wide
r_modepresets (DONE, patch 0023). Extendr_vidModes[]inshared/sdl/sdl_window.cppwith 1280×720, 1600×900, 1920×1080, 2560×1080, 2560×1440 (QHD), 3440×1440, 3840×1600, 3840×2160 (4K), and 5120×1440 (modes 13–21).r_mode -1(custom w/h) andr_mode -2(desktop) already worked and are documented as the other paths. -
G3 — wide FOV: document, do not force (DONE, docs only). The engine's
cg_fovAspectAdjustgives the correct Hor+ scaling; it is archived and user-set, and Hor+ vs vertical FOV is a preference, so [widescreen.md] tells players to enable it rather than flipping the default (which would only affect brand-new configs and override existing ones).
Possible follow-ups (not started): an in-menu resolution/FOV control that lists
the new modes and toggles cg_fovAspectAdjust; per-element 2D placement (draw
backgrounds full-bleed while pillarboxing only the HUD) if full-screen 4:3
menu art on ultrawide is judged worth the invasive per-call change.
| Order | Task | Why then |
|---|---|---|
| 1 | T0.1 | Everything else assumes it |
| 2 | C1 | Immediately useful, zero engine risk, exercises the docs |
| 3 | A1 → A2+A3 → A4 | The critical path; start it as soon as T0 works |
| 4 | A5 loop (with A6 when the gizmo annoys) | Bulk of the work; every iteration ships visible progress |
| 5 | D1 → D2 in gaps | Console-testable without Track A; good context-switch work |
| 6 | C2, C3 in gaps | CI pays off earliest; winsock is self-contained |
| 7 | D3 | Best after A-M2, when a joiner can see what they joined |
| 8 | E1 → E2 → E3 | Gated on A-M4 by design |
| 9 | C4 | Last: needs C2+C3 artifacts and a Windows test machine |
Everything above is done (C4 included — verified live on Windows). What remains, in suggested order:
| Order | Task | Why then |
|---|---|---|
| 1 | F1 → F2 | Establishes the state/event routing pattern; F2 is small once F1 exists |
| 2 | F4 investigation | Biggest unknown in Track F — scout it before committing to F3's shape |
| 3 | F3 | Depends on F2's event channel |
| 4 | F4 fix → F5 | Transition fix lands on the routing pattern; F5 seals the track |
| 5 | C6 | Hardware-gated (real Mac) — do whenever the hardware appears |