Commit Graph
4805 Commits
Author SHA1 Message Date
bearstonem d9968210aa fix(vr): correct visual glitches and reduce rendering overhead
Restore desktop HUD behavior and objective state, fix stereo text lifetimes, HUD placement and colors, sprite culling, and font shader passes. Handle swapchain timeouts and incomplete stereo frames safely, remove forced GPU waits, and correct rendering timing attribution.

Track client-game source additions in CMake. Validated desktop and Android GL1/GL2 builds plus targeted regression and sanitizer checks. Headset appearance and performance remain unverified.
2026-10-14 14:48:22 -07:00
bearstonem bd72599ab7 feat(vr): the HUD comes up when asked, and the compass is whole
The group is hidden now. The off hand's X brings it up for vr_hudTime seconds
and then it fades over 600ms. Almost everything it carries - how much ammo is
left, which way the objective is - is only wanted at the moment it is asked
for, and one that is always there is always in the way of a game about looking
at things.

The press is a timestamp rather than a toggle. The VR layer writes
Sys_Milliseconds into vr_hudShow on the button's down edge and cgame reads it
against cgi.Milliseconds, which is the same function, so there is no state to
latch and no edge to clear on the far side - and a second press during the fade
simply restarts it. Only the down edge counts: holding the button should not
keep restarting the timer, and should not hold the group open either, because a
button held down is a button the player has forgotten about. vr_hud 2 pins it
up, which is what to use while arguing with the layout.

X used to jump, and jump was also on the right hand's A, so it was a button
spent twice. The HUD is worth one of them and jump keeps A. Left Y is the
better candidate and is not used here: it still sends +scores for an objectives
overlay that no longer appears on its own, since the objectives moved into this
group and vr_objectivesAlways forces them on regardless. Moving the HUD there
would cost nothing, and would give X back to jump.

The compass has the two layers it was missing. The damage ring is
stats[STAT_DAMAGEDIR] drawn through compasspain, fading over 1500ms from
whenever the value last changed - it is a headingspinner, which unlike the
objective is the variant that does subtract the view angles, so the direction
arrives in world terms and has to be brought round to the head, and the +180 in
that is because the stat points at the attacker while the ring marks where the
damage came in. The objective is compassobjarrow turned to its angle rather
than the pip that stood in for it while nothing here could draw anything
rotated.

vr_hudGroupY is 0.92, which is 0.63 of the eye. 0.68 is as low as this can
reach at all - vr_hudScale compresses everything towards the middle of the
display, so past that the cvar has nothing left to give and the size has to come
down instead.

And a guard where the font bug actually lives. glGenTextures never legitimately
hands back 0 - it is the reserved name - so a zero means the call did not write,
and nothing downstream notices: Upload32 uploads into texture zero, GL_Bind
binds it, and the surface samples opaque black, which is what every string in
the game looked like. R_CreateImage now asks a second time and says so if it
still comes back empty.

That is a guard and not the cure, because the cause is still unknown. What is
known is that three of the four fonts recover and verdana-14 does not, that it
is the first one created, and that its image is alive and correctly named with
no texture attached - so it was never freed, and R_FreeImage and R_DeleteTextures
both memset, which is how that was ruled out. The trace prints every font atlas
as it is created, with its slot and its texnum, because from the outside an
image that was never created and one created without a texture are
indistinguishable by the time anything draws with it, and telling those apart is
the next step. Until it is found, CG_HudDrawElements still draws its text as
boxes.
2026-08-02 23:35:31 -07:00
bearstonem e6a7c76f84 feat(vr): one HUD in the middle of the view, and the two bugs that hid it
The retail HUD is five menus from ui/hud_*.urc, each aligned to a corner of a
4:3 screen. On a monitor those corners are a glance away; through a lens they
are the far edge, present and unreadable. cg_vrhud.cpp draws the same
information again as one group below the centre of the view.

Nothing is read back out of the widgets, because there is nothing in them to
read: every one is a formatter over cl.snap.ps.stats[], thirty-two integers,
and the .urc files are pure layout - hud_health.urc is "playerstat 0,
maxplayerstat 1, draw it as a vertical bar". cgame has the same array as
cg.snap->ps.stats[]. The .urc files were still worth reading, as the spec for
which stat index and which shader each element uses, and the decode of the two
compass angles is taken from the widgets that already do it rather than guessed:
the heading is the eye yaw less stats[STAT_COMPASSNORTH]/182, a sixteen bit
angle, and the objective is stat/10 - 180 and is already relative to the view,
because hud_compass.urc draws it as a plain spinner and not the headingspinner
variant that subtracts the view angles itself.

It is drawn from cgame rather than as another widget for a reason beyond taste.
cgame's 2D goes through the one full screen window UIView3D::set2D sets up, and
that window is converged as a whole. A widget would arrive with its own window,
which is the first bug below.

Convergence: the correction belongs to the display, not to the window.

R_VRAdjust2DOrtho bent whatever ortho window it was handed, and in this engine
that window is usually one widget - UIWidget::set2D cuts a viewport and an ortho
window to each widget's own frame. The correction was therefore scaled to the
widget, and a 150 pixel compass in a 1680 pixel eye got about a twelfth of the
shift it needed while the objectives, drawn through the full screen window, got
all of it. Every element ended up with a disparity in proportion to its own
width, so each sat at a different apparent depth, and no vergence resolves that
set - which is what "the HUD will not converge" was.

It is R_VRAdjust2DRect now, applied to the viewport and scissor rectangle in
eye buffer pixels, which every 2D window shares. The scissor has to move with
it or a widget the shift has displaced is clipped away at where it used to be.
vr_hudOffset goes back to 1 in autoexec.cfg: 0 was set on a measurement of the
old bug, and it is the one value that cannot fuse at all, because the centre of
each eye's buffer is about seven degrees off its own axis in opposite
directions, so it asks the eyes to diverge by fourteen degrees.

Text: the font atlases had no texture.

Every string drew as a flat black rectangle - 0,0,0 with no glyph structure,
measured off an eye dump rather than a compositor screenshot. The atlas is a 32
bit TGA with a real alpha channel and the shader is correct, so neither of the
obvious causes held. The shader's own stage reported texnum 0, and GL_Bind binds
image->texnum straight, so it was binding texture zero, which samples opaque
black.

The image is alive and correctly named, so it was never freed - R_FreeImage and
R_DeleteTextures both memset, and the name survived. texnum was zero from
creation. R_LoadFontShader now notices and reloads, but it has to free the image
first: R_FindImageFileOld looks in the hash before it looks on disk, so asking
by name returns the very image that has no texture. Three of the four fonts come
back with a texture; verdana-14, the first one created, still comes back as zero
on a fresh load from disk, which means qglGenTextures is not writing to it and
the real bug is upstream in image creation. That is not fixed here, and
CG_HudDrawElements still draws its text as boxes because of it.

Also in here: R_DrawTrianglePic added to the cgame imports, which the UI has
always had as re.DrawTrianglePic and is the only way to draw anything rotated,
so the compass card can turn under a fixed needle the way hud_compass.urc does
it. The retail corner HUD is hidden from the "Hide the HUD when necessary" block
in UI_Update rather than from the explicit Display calls above it, because that
branch returns early and is not the path taken - when it is not, UpdateViews
walks the whole tree and draws every shown menu regardless, which is how
hud_health and hud_ammo stayed on screen through the first attempt.

Bars are R_DrawBox rather than a stretched gfx/2d/blank. That shader is declared
alphaGen oneMinusVertex, which inverts the alpha: the bars asked for at 1 came
out at 0 and the backdrop asked for at 0.35 came out at 0.65.

vr_hudGroupY is 0.73 and does not mean what it looks like. It places the cluster
in the flat frame, which is then scaled to vr_hudScale about the middle of the
display and lifted by the eye's vertical frustum asymmetry, 170 pixels of 1760
here - so 0.73 is a little below the middle of what is actually seen and 0.62
was a little above it. The reachable range is about 0.13 to 0.68 of the eye.

Not done: the damage direction ring, the objective as compassobjarrow rather
than a pip, and the compass worth having, which is one the player takes out of a
pocket and holds.
2026-08-02 23:16:00 -07:00
bearstonemandClaude Opus 5 bec1f4a8d5 fix(render): stop dynamic lights darkening the world, and record why
Firing in the Bocage multiplies roughly a sixth of the screen down by about 4x
for as long as the trigger is held. It is the ground plane, with hard polygon
edges, and it is not black - the texture is still there underneath. r_worldDlights
0 in autoexec.cfg stops the world responding to dynamic lights at all, which
loses flashes lighting the scenery and is the smaller loss.

The cause is not found. Seven theories were tried and all seven were wrong, so
what this mostly commits is the evidence, in docs/markdown/04-coding/05-dlights.md
next to the gl4es notes, so that picking it up again does not start from nothing:
the cadence table and the frame difference that measured the 4x, a table of every
dead theory with what killed it, the worked texcoord arithmetic showing the faces
land correctly, and the one hard contradiction to resume on - every component of
the dynamic lightmap path measures correct end to end, and switching the path off
makes the symptom stop.

Two things in there are recorded specifically because they look damning and are
not. "texels 9275" against "rows 28" cannot fit, but R_RealDlightFace counts
c_dlightTexels before it knows whether any light landed and then bails at
!added without committing to dli.allocated, so failed surfaces are counted and
take no space. And R_UploadDlights' guard on tr.pc.c_dlightSurfaces reads like it
must always be zero from the back end, because R_IssueRenderCommands clears the
counters before running the command list - it is not what happens, measured at 35
surfaces. That guard is gone anyway: the height below it is the same test done
against the allocator's own state rather than against a debug counter.

The workaround is its own cvar rather than a level of vr_traceDlight, which is
what it was while it was being tested. A diagnostic switch that also changes
behaviour means turning tracing off silently brings the bug back, and this port
has already lost a day to a cvar that did not do what its name said.

The diagnostics stay, off. 1 reports what each upload contained and where every
dlit face landed in the atlas; 2 stains the whole atlas white and paints the lit
texels magenta, which is read off the walls rather than out of the log; 3 skips
ProjectDlightTexture, which was measured never to run here and is kept because
proving that cost a run. The first stain was dark green and was useless in a
level made of hedgerows - white can only brighten, so it cannot be mistaken for
foliage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:12:39 -07:00
bearstonemandClaude Opus 5 6f443949ae fix(vr): the death screen was drawn on the player's face
The stats screen the game raises when the player dies, and again at the end of
a mission, went into both eye buffers as screen space 2D - at the near plane, a
hand's width away, with no disparity to fuse and each eye warping it through its
own asymmetric frustum. That is the same fault 013b2f5b fixed for the mission
briefing, and the fix there did not cover this one for two separate reasons.

cg_view.c raises it with UI_ShowMenu, which sends the widget a showmenu event
directly instead of going through MenuManager. The menu never reaches either
menu stack, so CurrentMenu() does not return it and UI_FullscreenMenuUp() cannot
see it however fullscreen it is.

And it is not fullscreen: statsscreen_failed.urc has its "fullscreen 1" line
commented out. It is a 640x480 overlay the flat game draws over the live view,
so even as the current menu it would still answer no.

So neither test could have caught it, and asking a third question about menus
would have been guessing at the next one. It now asks about intermission, which
is the same condition CG_DrawActiveFrame itself uses to decide to put the screen
up - if the game thinks it is showing a stats screen, the panel is where it
goes. That also covers mission success, the MOHTA mission_* screens and the
multiplayer scoreboard, which are the same kind of content with the same
problem.

Confirmed on the device: the screen is now a quad in the room. Its text renders
as black boxes with a sliver of the font atlas showing through, which is a
separate defect and untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:12:19 -07:00
bearstonemandClaude Opus 5 711cad20e6 feat(vr): reach into the world - use, holsters, ladders and thrown grenades
Four interactions that all needed the same thing: the hands' positions and
velocities on the other side of the game module boundary. They share the
plumbing, so they share a commit.

use moves to B on the weapon hand, where the reference puts activate. The
objectives list keeps the left hand's button; it had been bound to both, which
was a button spent twice on one rarely opened screen. The trace also starts from
the head's real position now rather than the body's notional eye - the two come
apart exactly when leaning through a doorway to reach the thing on the far side.

Weapons live on the body, the way Half-Life: Alyx does it: pistol at the right
hip, grenades at the left, rifle and smg over the shoulders. Reaching back to
where a weapon came from puts it away, reaching anywhere else swaps - holstering
on any zone would have cost two gestures for every swap. Zones are measured from
the head and rotated by the head's heading, so they stay on the body rather than
wandering as the player turns.

Ladders are climbed by pulling on them. MOHAA moves a climbing player by the
root motion of the climb animation, with pmove skipped entirely, so this runs
after the animation rather than instead of it: the animation still plays and the
distance travelled is the distance the hand pulled. Both grips, averaged when
both are held, and the grips stop meaning reload and rifle-steady while on a
ladder.

Grenades leave along the arm. Which instant of a swing to read is the whole
problem and took several passes: averaging across the release blends the wind up
with the follow through; the peak of the whole swing is usually the *backswing*,
which threw grenades over the player's shoulder. What works is the natural
gesture's own shape - take the direction the hand had as it let go, then find
the fastest sample that agrees with it, which excludes the wind up by
construction. Strength is a fraction of the game's own throw so all of MOHAA's
grenade tuning survives.

The sideways damping is the one deliberate lie. An underhand swing crosses the
body, so the hand really is moving sideways at release - measured, lobs drifted
left by up to 0.73, more sideways than forward. Damped in proportion to how
gentle the throw is, so hard throws keep their true direction.

A weapon wheel is included and is not finished: its input works and a tap of the
same stick holsters, but the ring never appeared on screen and the drawing was
not chased down. It is harmless - a hold selects nothing and does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:25 -07:00
bearstonemandClaude Opus 5 15471f7578 feat(vr): a hand is always drawn, and the weapon hand is only a hand
Two changes to what the first person model is for.

Being unarmed is not a reason to have no hands. Flat, that model exists only to
hold a weapon, so putting the gun away hides the arms with it and nobody
notices; in a headset the player is holding two controllers whether or not there
is anything in them, and hands that vanish read as tracking having dropped out.
No mesh work was needed - triggerhand and lefthand are already separate
surfaces, so the unarmed case shows those two and hides garandhand, a left hand
posed around a rifle that is not there. Zoom, death and cg_drawviewmodel still
hide, being about something else entirely.

The weapon hand's copy now draws triggerhand alone. viewsleeves is a single mesh
carrying *both* forearms - weighted 27% to Bip01 R Forearm and 27% to
Bip01 L Forearm, with both elbows, shoulders and clavicles - so drawing it on a
model anchored to the right controller hung a disembodied left sleeve in the air
beside it. What is left is a whole hand rather than a stump: triggerhand is
weighted to Bip01 R Hand, every R finger bone, and helper Rwrist. The wrist is
in, the forearm is not, and there is no way to get part of one without
re-authoring - the forearm exists only inside the two-armed sleeve.

Those weights were read out of the retail USarmyplyr.skd rather than guessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:00 -07:00
bearstonemandClaude Opus 5 74dc964962 fix(vr): the use trace was aimed at the player's feet
Use worked about half the time, and the half it worked was luck.

cg.refdefViewAngles is not only the view. CG_OffsetFirstPersonView overwrites it
with the player model's animated *head bone* orientation on the paths that place
the view from a world position, and CG_EyeAngles samples whatever is left there
when the packet is written. Those angles travel up in the usercmd as usereyes_t
and become Player::m_vViewAng, which is what getUseableEntities aims with.

Measured on device, that left a pitch pinned between 73 and 86 degrees. Every
press traced about 77 units into the floor and stopped there - the trace
fractions were 0.87 to 0.93 - and the box at the end of it caught the player's
own entity plus whatever trigger happened to be underfoot. Standing on a
TriggerUse was what "working" meant.

In VR the eye angles now come from the player state's view angles, which are the
headset's and only the headset's: CL_VRMove writes the head's yaw and pitch into
them every frame and nothing animated gets a say. No recoil kick or lean in them
either, which is right for reaching at a door handle.

Flat play keeps the old source. There the two agree except for kick and lean,
and changing where a desktop player's use is aimed is not what this is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:00 -07:00
bearstonemandClaude Opus 5 aa2fbeaf94 fix(vr): the hand rotation the models need is the default, not an override
vr_weaponAdjust and vr_offHandAdjust both defaulted to a zero roll, and the 180
that makes the hands come out the right way up existed only in
misc/android/autoexec.cfg. That file is deliberately not packaged into the APK
- it lives next to the game data, so that the config survives reinstalling the
build - which means anyone installing the APK on its own got upside-down hands
and no way to guess why.

Both defaults are now 180, which is the value measured on device rather than a
guess. The config still sets them, so it stays the place to tune them from;
it is no longer the only place the right answer exists.

The comment explains why the number is not arbitrary: anchoring forces the
bone to take the controller's orientation, and a wrist bone's local axes do not
run the way a controller is held, so the mesh inherits that mismatch as a
constant half turn about the aim axis. The two hands are mirrored and were
still measured to need the same 180, which is why they remain two cvars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:35:03 -07:00
bearstonemandClaude Opus 5 214933c5fe perf(vr): render the eyes at the resolution the runtime asks for
vr_resolutionScale has been at 0.6 since the days when a frame cost 50-60 ms per
eye, which meant the headset was being given 1008x1056 against a native
1680x1760 - 36% of the pixels it has.

The measurement that justified it has since answered the opposite question.
Rendering at 36% of the pixels bought only 23% of the frame time back, which is
what proved the cost was never fill rate: it was CPU in rend2's back end binding
a GLSL program per surface per eye. Swapping to renderergl1 on gl4es took the eye
from 50-60 ms to 4-6, and nothing has claimed that budget since.

So it goes back to 1. The cvar stays rather than being deleted - it is still the
first question worth asking of any frame time regression, and it is the cheapest
thing to trade if a later feature wants budget it has not got.

Measured on the device afterwards, in the Bocage:

    standing at spawn   90.0 fps   eyes 8-9 ms
    moving in the level 77-85 fps  eyes 11-12 ms, most frames over the 11 ms period

So this does not hold 90 Hz while moving and the compositor is reprojecting a
good share of frames. Kept anyway, on the judgement of how it actually plays -
the picture is much sharper and the reprojection is not felt. The number is
recorded here so the trade is a known one rather than a surprise to whoever reads
the frame times next.

Two things follow. There is now no headroom for MSAA. And the frame split cannot
be taken at face value: it reports world 1 ms against hud 5 ms, which is the
Set2DWindow flush attributing the world's deferred draw calls to the first 2D
call that forces them out - the trap in QUEST_PORTING_GUIDE.md section 7.1. The
one figure that is trustworthy is gpuwait, still 3 ms, so the GPU remains idle
and whatever the 11 ms is, it is CPU side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:23:14 -07:00
bearstonemandClaude Opus 5 7ce4e07352 fix(vr): scale the world by the game's own number, and the model by its own field
Reported as the hand and weapon models rendering too large. Two causes, one of
them the same knob failing to do what its name says.

**vr_worldscale was 32.** That is roughly the id Tech number for a game built in
inches and is simply the wrong engine's. MOHAA's art is authored at 16 units per
foot, which its own tik files state where they convert from centimetres:

    models/weapons/bar.tik:
    scale 0.52   // 16/30.5 since world is in 16 units per foot

16 / 0.3048 = 52.5 units to the metre, and the player dimensions agree - MAXS_Z
94 and DEFAULT_VIEWHEIGHT 82 come to 1.79 m standing and 1.56 m to the eye, where
32 would have made the player over two and a half metres tall.

Being out by a factor of 1.64 does not present as a scale error. Everything
measured in metres and handed to the game was too small, so a hand held half a
metre out was placed at 16 units rather than 26 - while the hand *model* is
authored at the game's true scale. A correctly sized model drawn far too close
reads as oversized, and there is nothing in the world to compare it against. Your
own hands are the only reference object, which is why this surfaced now and not
when the world first rendered.

Set in autoexec.cfg as well as changed in code, because vr_worldscale is
CVAR_ARCHIVE and the value already stored on the device beats any new default.
That trap is documented two sections above the one this was found in.

**The adjustment string's scale field did nothing to the model.** It is
scale,right,up,forward,pitch,yaw,roll, and the reference uses the first field as
the entity scale - VrInputWeaponAlign.c reads it into test_scale and cg_weapons.c
returns it as the model's scale. Here it only ever scaled the offset, so it was a
knob that appeared inert on a model that was the wrong size. Now applied to
model->scale, and applied before anything asks for a tag: TIKI_Orientation
multiplies the tag origin by the entity scale, so anchoring first and scaling
afterwards would slide the hand off the controller by whatever the scale changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:56:46 -07:00
bearstonemandClaude Opus 5 4f5b54c74c feat(vr): a hand on each controller, placed in the renderer's frame
The first person model already has the hands as separate surfaces - triggerhand,
lefthand, garandhand and viewsleeves on USarmyplyr.skd - so putting one on each
controller needs no mesh work at all. The weapon hand's copy drops the left hand
surface and a second copy is drawn on the off hand carrying only that, using the
same Surface_NameToNum and MDL_SURFACE_NODRAW the game already uses to swap
lefthand for garandhand on rifles.

Two things had to be right first, and both were wrong in ways that looked like
tuning problems rather than faults.

**The frame.** cg.refdef.vieworg is not the camera. R_VRComposeView adds the
head's play space offset to it, rotated into the body's frame, after cgame is
finished (tr_vr.c). So anything anchored on vieworg alone is left behind by
exactly the head's offset within the play space - and because the head swings
about the neck, every look around moved it, and a snap turn left it behind
entirely since a stick turn moves the body frame that a head relative offset
knows nothing about.

Both hands are now reported measured from the tracking origin, the frame
VR_PoseToView puts the eyes in, and composed the way the renderer composes the
camera:

    bodyAngles = (0, viewYaw - baseYaw, 0)
    handWorld  = vieworg + bodyAxis * trackingOffset

RTCWQuest reaches the same place by a different road - convertFromVR rotates a
head relative offset by viewangles[YAW] - hmdorientation[YAW] and the -64 plus
hmdposition[1] * worldScale rebase then replaces its height, which comes to the
same thing. Either route works. Taking half of each does not, and did not: a
head relative offset composed as though it were play space leaves the hand at
vieworg + R*(hand - head) while the camera is at vieworg + R*head, so the head's
movement is counted twice and every hand movement is amplified. That is what
"the right hand is accelerated" was, and it happened because VR_GetWeaponPose
was left reporting the old frame while its caller had moved to the new one - the
signature is identical either way, so nothing complained. The header now says so
at length.

**The anchor.** A hand drawn at the model origin sits wherever the animation put
it, which for a rifle is a long way out in front. The model is now moved so the
hand's own bone lands on the controller instead, inverting the composition the
engine itself uses in Entity::GetTagPositionAndOrientation. The off hand anchors
on Bip01 L Hand and the weapon hand on tag_weapon_right, because what the player
is holding there is the gun and the controller is its grip.

Every candidate anchor is measured and logged rather than trusted, which earned
itself immediately: tag_weapon_left was assumed to be an unused slot parked at
the origin and is nothing of the sort (23.5 units out), while the weapon hand's
lookup was returning -1 in silence and anchoring nothing. Both now report what
they found and how far out it is, and the early return says which precondition
failed.

Tracking is confirmed 1:1 on both controllers on the device. What is left is the
wrist bones' own rotation convention, which is a constant and lives in
vr_weaponAdjust and vr_offHandAdjust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:45:57 -07:00
bearstonemandClaude Opus 5 48b5876e3f fix(vr): the menu is CA_CINEMATIC, so the start map never loaded
vr_startMap waits for the client to sit idle before loading, because a map
started from autoexec.cfg runs during Com_Init and the game's own startup tears
it straight back down again. It counted only CA_DISCONNECTED as idle.

MOHAA's main menu is drawn over a static pic, and the enum says what that means:

    CA_CINEMATIC   // playing a cinematic or a static pic, not connected to a server

So the client is CA_CINEMATIC at the menu and never CA_DISCONNECTED. idleSince
was cleared every frame the menu was up, the four second wait could never
accumulate, and the map simply never loaded. Eight seconds sitting at the menu
was plenty; the timer just kept resetting. Confirmed by the state it now prints:
9, which is CA_CINEMATIC.

The reason this took a round trip to find is worth more than the fix. The
vr_testStart branch three lines below prints either way, and says why:

    // Printed either way. A hook that silently does nothing is indistinguishable
    // from one that never ran, and telling those apart has cost enough already.

vr_startMap printed only on success, so its silence could not be told from "ran
with an empty cvar". It now prints unconditionally and names the state that let
it through, so the next time this misbehaves the log says which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:45:31 -07:00
bearstonemandClaude Opus 5 bd480975a3 fix(vr): stop the driver encoding the engine's colour a second time
Reported as flat lighting - the whole scene lit from everywhere, shadowed faces
pulled up towards lit ones. That reads as a lighting fault and is not one: the
shading is untouched, and a defect that moves the entire scene by one curve is
the pipeline rather than any model's normals.

The swapchains have always been GL_SRGB8_ALPHA8, which is the right declaration.
This engine's output is display referred - the same values a monitor would have
been handed in 2002 - so telling the compositor the images are sRGB is what makes
it pass them through instead of encoding them again.

What the format does not do on its own is stop GL encoding on the way *in*.
ES 3.0 converts linear to sRGB on every write into an sRGB attachment, blits
included, and core ES has no state to turn that off - GL_EXT_sRGB_write_control
is what adds the switch, and it defaults to on. So the driver was encoding
already encoded colour and the compositor was faithfully passing it through:

    stored = encode(V), displayed = encode(V)

which is the same lifted mid tones as declaring the images linear, reached from
the other side. The format line looks like the fix and is half of it, which is
why this survived so long - a port that has done only that half reads as one
that cannot possibly have the washed out bug.

Measured on the Homeworld port, where the same defect arrived by the linear
route: a mid tone the data puts at 0.19 came out at 0.45, against 0.473 for a
straight sRGB encode of it, and the ratio between the brightest and darkest
tenth of a hull collapsed from about 5:1 to 1.76:1.

Not routed through gl4es. gl4es keeps no copy of this enable and never sets it
itself, so disabling it once holds for the whole run, the engine's own rendering
through gl4es included.

Confirmed on the device: "sRGB write conversion off" in the log, and the picture
is correct - which means considerably darker. That is how the game is meant to
look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:45:20 -07:00
bearstonemandClaude Opus 5 013b2f5bbf fix(vr): put in-game fullscreen menus on the panel, and load the test map late
Three things, all found by running it.

The mission briefing was drawn onto the player's face. VR_UseScreenLayer only
asked whether clc.state was CA_ACTIVE, so a fullscreen menu that comes up while
the player is still in the world skipped the quad layer and was drawn as screen
space 2D into both eye buffers - at the near plane, a hand's width away, with no
disparity to fuse and each eye warping it through its own asymmetric frustum.
That is exactly what the panel exists to prevent; nothing in-game was reaching
for it. It now checks UI_FullscreenMenuUp as well.

The log said so before the headset did: "view3d 0" with the eye passes still
running means the 3D scene is not being drawn at all, which only happens when a
fullscreen menu is covering it.

map in autoexec.cfg never had a chance. That file is exec'd during Com_Init and
the game's own startup - the intro stages, then the menu - runs afterwards and
tears down anything loaded there. vr_startMap now waits until the client is
sitting idle at the menu and loads it then. vr_testStart also prints
unconditionally: a hook that silently does nothing is indistinguishable from one
that never ran, and telling those apart has cost enough already.

vr_captureEye is a plain archived cvar rather than a VR_TuningCvar. Those force
their default at every start, which is right for a value still being argued with
and wrong for a switch - it made the config's own setting impossible to honour
and cost a device run to notice. The handoff warns about this exact trap.

vr_traceFrame is off in the shipped config: three lines a second rolls anything
that prints once out of logcat within a minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:46 -07:00
bearstonemandClaude Opus 5 ce91fd1bf7 feat(vr): per weapon alignment, and a way to test it without replaying the level
The view model is authored to sit at the camera with the arms reaching forward,
so anchoring its origin at the controller leaves that built-in offset in place
and the gun lands near the player's elbow. Rotation was already correct; only
the position was wrong, which is exactly what this fixes and exactly why no
amount of getting the hand pose right removes the need for it.

Same mechanism as the reference's vr_weapon_adjustment_<id> (rtcw
cg_weapons.c, VrInputWeaponAlign.c), as one tunable cvar:

    vr_weaponAdjust "scale,right,up,forward,pitch,yaw,roll"

Two details from theirs that are easy to get wrong and are kept here. The
rotation correction is composed in the weapon's own frame rather than added to
the angles, which is only right for one axis at a time. And the offset is
applied along the corrected axes, after the rotation - that ordering is why a
model can rotate correctly and still sit in the wrong place. It is a cvar so it
can be dialled in by pushing a config and relaunching, with no rebuild in the
loop.

CL_RunStartupCommand runs vr_testStart once, a second and a half after the
player is in the world. Setting up a weapon test otherwise means playing through
to wherever the game hands one over on every single build; there is no console
in a headset to type "give" into, and autoexec runs before there is a map, let
alone a player to give anything to. The delay is because CA_ACTIVE arrives
before the player has finished spawning and a give aimed at nothing is dropped.
It re-arms on every map load rather than firing only once per run.

The reference keeps an adjustment per weapon. This is a single global one until
the numbers for each are known - which is what the harness is for.

Not yet run: the headset dropped its connection before this could be installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:39:04 -07:00
bearstonemandClaude Opus 5 db8dfae0bd feat(vr): reload on a tap of the weapon hand's grip
The reference's gesture (VrInputDefault.c): press and release the dominant grip
inside vr_reloadtimeoutms and it sends +reload, then -reload a frame later
because the game wants to see the key held for a tick before it is let go. A
long hold is their weapon wheel, which this port does not have, so holding
simply does nothing rather than reloading late.

The weapon hand grip was a second binding for use. Use stays on the right stick
click, which is where the reference has it too - +activate on ovrButton_Joystick
- so nothing is lost.

vr_reloadTapMs is their vr_reloadtimeoutms under this port's naming.

Built, not yet run: the headset has been unreachable since the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:12:06 -07:00
bearstonemandClaude Opus 5 046e2dd9a6 feat(vr): put the weapon in the player's hand, from the reference
Four pieces, each adapted from RTCWQuest rather than invented.

Aim. Weapon::GetMuzzlePosition derives a shot from player->m_vViewAng, which is
MOHAA's analogue of rtcw g_weapon.c:1904. It now takes the controller's pitch
and the yaw the hand leads the head by, added to the view yaw the game already
has - the controller's absolute yaw means nothing to the game, since the player
can snap turn on the stick and the play space has an arbitrary forward. Only the
trace moves; the camera stays on the head.

The angles reach fgame through gameImport_t rather than the usercmd, which has
no spare field - angles[3] is the view direction and buttons is packed. A
dedicated server answers false and keeps the aim it had.

Placement. The first person model is MOHAA's FPS view model, positioned by
CG_OffsetFirstPersonView against the camera and given the walk bob. Both are
wrong in a headset: the hand has its own position, and shaking the view model
when the camera is the player's head is a way to make people ill. It now goes
where the hand is - RTCWQuest's CG_CalculateVRWeaponPosition, including
dropping the origin to the feet and adding the measured head height back so the
weapon hangs at the height the player is really holding it, rather than off the
camera where crouching would drag it about.

Selection. Nothing was bound to it at all, and there is no button left - the
triggers fire, both face buttons on each hand are objectives and jump, and the
sticks and grips are duck and use. The reference does not spend a button either:
the dominant thumbstick up is weapprev and down is weapnext, gated on the
sideways component so it cannot be read as a turn and edge triggered so a held
stick does not run the inventory. This port's turn already ignores that stick's
Y axis, so nothing was displaced.

Two handed hold. Off hand grip squeezed with the hands close together points
the weapon along the line between them instead of along the weapon hand alone -
how a rifle is actually held, and steadier, because a wobble at one hand is
damped by the distance to the other. Roll is halved rather than taken from the
line, which carries none. The off hand grip was a duplicate of use; the weapon
hand's still opens doors.

The suggested binding array was 16 with 14 in use. Now 32.

Aim and placement are built but not yet confirmed on the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:10:24 -07:00
bearstonemandClaude Opus 5 862d36a9ba chore(vr): take the diagnostics back out
Removes r_flatColor, r_traceSurf, r_forceGenericStage, r_noCull, r_noDepth,
r_noFog, r_invertCull, vr_debugPanel and its magenta panel clear, along with
R_TraceSurface, R_DriverGet, R_FlatColorPush/Pop and the GetFloatv/IsEnabled
entries added to qgl.h for them. Around 570 lines.

Everything they were built to find is fixed, and the fixes are all still here:
the cull face forced past gl4es's cache, RB_SurfaceFace's index type, the window
resize vid_restart, the frustum built from the runtime's tangents, the terrain
cone sized to the headset, and the VR layer's state calls routed through gl4es.
Verified rendering on the device after the strip, with no overrides at all.

vr_captureEye stays, defaulted off. It writes the eye buffer to a TGA and it is
the one probe that consistently earned its keep - a picture settled in one look
what rounds of description could not. Inert unless switched on.

misc/android/autoexec.cfg is down to that single line. Nothing in it is load
bearing any more.

Also records a third instance of the session's recurring mistake in the gl4es
doc: r_noDepth was declared, registered and tested for several device runs while
the edit meant to read it had silently failed to apply. Every result attributed
to it was a no-op, and it sat in the "known good" configuration taking credit for
what disabling culling was actually doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:38:40 -07:00
bearstonemandClaude Opus 5 5e7e907792 fix(renderer): force the cull face past gl4es's cache, and the world appears
gl4es keeps its own copy of the cull face and returns early from glCullFace
whenever the requested mode matches it (src/gl/face.c:14). Measured on the
device, past gl4es and straight out of libGLESv3, its copy said GL_FRONT while
the driver was culling GL_BACK. So the engine asked for GL_FRONT every frame,
gl4es judged that redundant against a copy that had drifted, and the hardware
went on removing exactly the faces Quake wants kept - its world polygons are
back facing in GL's terms, which is why CT_FRONT_SIDED culls GL_FRONT at all.

The symptom was the entire world invisible while models rendered perfectly,
because model shaders are CT_TWO_SIDED and never enable culling. Inverting the
culled face did not help either, because asking for GL_BACK matched what the
driver was already doing. Only disabling culling outright brought the world
back, which is what made this look like a winding fault for so long.

GL_Cull now names GL_FRONT_AND_BACK first, so the mode it actually wants cannot
be mistaken for a no-op. Nothing is drawn between the two calls.

With this, and with the vid_restart and RB_SurfaceFace index fixes, the world
renders with no overrides at all: terrain, BSP, models, lightmaps, fog and sky,
at 89-90 fps and 6 ms per eye against rend2's 50-60.

Handoff section 9 is rewritten around what this cost to find: gl4es answers
glGetIntegerv from its own tables, so every "GL and the engine agree"
measurement taken through qgl compared the engine against a copy of itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:28:56 -07:00
bearstonemandClaude Opus 5 e967529ec6 fix(renderer): ask the driver for GL state, not gl4es's copy of the question
gl4es answers glGetIntegerv for GL_CULL_FACE_MODE, GL_FRONT_FACE and
GL_MODELVIEW_MATRIX out of its own tables (src/gl/getter.c), the same way it
answers glGetString from constants - the trap section 5.5 already records. Two
measurements in this session read back "GL" state through qgl, compared it
against the engine, found it identical, and concluded the state was correct.
They were comparing the engine against gl4es's copy of the engine. That agrees
by construction and proves nothing, and both conclusions drawn from it were
worthless.

It matters because gl4es also drops calls its cache thinks are redundant -
glCullFace returns early when the mode already matches (src/gl/face.c:14), as do
glDepthMask, glDepthFunc, glBlendFunc, glColorMask, glScissor and glViewport. So
the driver can hold something the engine never asked for while gl4es reports the
engine's own value straight back at it.

r_traceSurf now resolves glGetIntegerv and glIsEnabled out of libGLESv3 directly
and prints the driver's answer next to gl4es's. The open question it is aimed at:
world geometry disappears with backface culling enabled whichever face is named,
while CT_TWO_SIDED models are unaffected. No winding fault does that, but a
driver cull mode of GL_FRONT_AND_BACK would, and gl4es would still report
GL_FRONT.

Not tested on the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:02:06 -07:00
bearstonemandClaude Opus 5 5267bc66c0 fix(vr): size the terrain visibility cone to the headset, not the game's fov
MOHAA decides which terrain patches are visible with a cone test:
R_MarkTerrainPatch rejects anything further off the view axis than
g_fClipDotSquared allows, and that value is a table lookup indexed by
tr.refdef.fov_x. At the game's 80 degrees the half angle is about 46.

In VR nothing else reads fov_x any more. The projection is built from the
runtime's four tangents in R_SetupProjection, and the culling frustum from the
same in R_SetupFrustum. So refdef.fov_x stayed at whatever the game asked for
while a Quest 3 eye sees closer to 100 degrees, and terrain that was inside the
headset's view but outside an 80 degree cone was never marked visible and never
drawn - with nothing anywhere reporting it, because that cvar has no other
consumer left in this path.

The fov now comes from the widest horizontal tangent when a headset view is
active. Widest rather than the sum: the per-eye frustum is asymmetric and the
cone is symmetric about the view axis, so it has to cover the wider side.

The index is also clamped away from zero. g_fDistanceTable[0] is
443.5 / sqrt(1 - 1) - infinity - which poisons g_vClipOrigin and culls every
patch in the level. Not reachable today, but nothing checked, and the failure
would be total and silent.

MOHAA specific: RTCWQuest has no terrain, so the reference could not have caught
this one.

Not yet tested on the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:46:53 -07:00
bearstonemandClaude Opus 5 b49e34a9ed fix(vr): stop the window resize restart, and the indices the world was drawn with
Two bugs, both found on the device, both with the evidence recorded in
VR_RENDERER_HANDOFF.md sections 8 and 9.

The menu was a renderer restart. SDL reports a window resize; the handler
compares it against glconfig.vidWidth/Height, which in VR is the eye buffer and
never the Android surface, so it can never match. It set r_customwidth,
r_customheight and r_mode -1 - cvars the renderer then ignores, because the VR
resolution comes from GetVRRenderResolution - and scheduled a vid_restart that
destroyed and rebuilt the GL context and the OpenXR session under a running
frame loop. The panel drew a full 256/256 samples up to CL_Vid_Restart_f and
nothing ever after. VR_PORT_STATUS called that restart harmless; it was the
whole bug.

RB_SurfaceFace declared its output pointer as unsigned* and assigned
tess.indexes to it, which is glIndex_t* - unsigned short under gl4es. Every
index went in four bytes apart in a two byte array, so each triangle picked up
whichever vertex the zero high halves pointed at. That was the world smeared
into streaks that dragged its textures with it; models never come through that
function, which is why they were always correct. ProjectDlightTexture had the
same fault in hitIndexes. The compiler had been reporting it all along, under
sixty other warnings in that file.

Also: renderergl1 culled against the game's symmetric fov rather than the
runtime's asymmetric per-eye frustum; the VR layer set viewport, cull, depth,
blend, colour mask and scissor state natively while gl4es caches all of them and
drops the engine's next request for a value its stale copy already claims; and
GfxInfo_f reported a different draw route than R_DrawElements took.

The remaining diagnostics are CVAR_TEMP and default off. vr_captureEye is worth
keeping: it writes the eye buffer to a TGA, and one image settled questions that
six rounds of verbal description could not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:29:43 -07:00
bearstonemandClaude Opus 5 27011bd3eb docs: hand off the renderer swap with what is proven and what is not
renderergl1 on gl4es runs on the headset at 90fps and draws the engine's own
geometry - the intro logo screen renders under a flat colour override. The main
menu still draws nothing, and that is the one open problem.

The largest blocker turned out to be the gl4es build itself: with CMake's flags
it cleared framebuffers perfectly and rendered no geometry whatsoever, measured
as 144 red from a clear and 0 green from a quad into the same buffer moments
later. Matching RTCWQuest's Android.mk fixed it outright with no engine-side
change. gl4es's own CMakeLists adds -DANDROID under the NDK toolchain and
ndk-build never does, so the reference has only been exercised without it, and
it gates the code that decides what the fixed function pipeline may emit.

Recorded at length because it invalidated most of a session's work: while gl4es
could not draw, every experiment ending "and nothing was drawn" returned the
same answer whatever it was testing. Four changes were adopted on the strength
of such results and are now marked for re-testing rather than trusted. The
document says plainly which findings are measured and which are not.

Also commits the framebuffer helpers the VR layer needs: gl4es has to own the
GL_FRAMEBUFFER binding or it renders from its own table into a 16x16 pbuffer,
while the swapchain texture attachment and the blit's read and draw bindings
must stay with the driver - routing those through gl4es empties the blit and
takes the cutscenes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:48:49 -07:00
bearstonemandClaude Opus 5 17771047c5 fix(vr): stop binding the eye framebuffer through gl4es
The headset was black while the frame loop ran at a contented 90 fps and the
audio played. Everything was being drawn into the window nobody looks at.

Routing the renderer's glBindFramebuffer through gl4es - so that gl4es would see
every call the renderer makes - is right in general and wrong for this call in
particular. gl4es can only bind framebuffers it created itself: its
glBindFramebuffer looks the name up in its own table, and every framebuffer the
VR layer owns comes from the driver's glGenFramebuffers. The lookup misses, it
raises GL_INVALID_VALUE, returns without binding, and goes on believing
framebuffer 0 is current. So the eye redirect quietly did nothing.

RTCWQuest's renderer never binds a framebuffer at all - Quake 3's fixed function
path predates them - and TBXR_Common binds the eye directly, leaving gl4es to
draw into whatever the driver has. renderergl1 is the same renderer and this now
does the same thing.

What the renderer is still told about is the flush. gl4es batches geometry and
issues it lazily, so work built for one eye would otherwise arrive in whichever
target is bound when it finally goes out. RE_SetDefaultFramebuffer flushes while
the old target is still current and binds nothing, which is also why the VR
layer calls it before its own bind rather than after.

readfboBegin and readfboEnd early out while gl4es's read and draw framebuffers
are the same, which they always are here, so gl4es does not rebind behind this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:12:04 -07:00
bearstonemandClaude Opus 5 d9960e1857 fix(vr): bind the engine's GL context against a pbuffer, as RTCWQuest does
The OpenXR session could not be created: eglGetCurrentContext returned nothing
while SDL was certain a context was current.

SDL binds the GL context against the window's EGL surface. In a headset there is
no window being presented - every pixel reaches the display through the
swapchains - so that surface never arrives. What SDL does then is the trap.
SDL_EGL_MakeCurrent, handed no surface and without gl_allow_no_surface, calls
eglMakeCurrent(EGL_NO_SURFACE, EGL_NO_CONTEXT) to unbind everything and returns
success; SDL_GL_MakeCurrent then records the context as current. So SDL's
bookkeeping and EGL's actual state disagree permanently, and asking SDL to bind
it again does nothing, because SDL_GL_MakeCurrent sees its own record agree and
returns early.

RTCWQuest never meets this because it never depends on a window surface: it
makes its context current against a 16x16 pbuffer (TBXR_Common.c,
egl->TinySurface). Same here, except the context is the one SDL already made,
since the engine renders in it. The EGLConfig comes from eglQueryContext, which
VR_CreateSession was already recovering for the graphics binding.

The pbuffer outlives the session deliberately - the session is torn down and
rebuilt around renderer restarts, and unbinding the context in the middle of
that is the thing this prevents. It is released in VR_Shutdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:08:14 -07:00
bearstonemandClaude Opus 5 a8a1314c2d fix(vr): ask for an ES context for the fixed function renderer, and check it is current
Two problems the first device run found, one hiding the other.

GLimp_SetMode's fixedFunction branch offers exactly one context: desktop OpenGL
1.1. There is no such thing on Android, so window creation failed three times
over and R_Init gave up with "Couldn't get a visual". renderergl2 never met this
because it takes the other branch, which already knows how to ask for ES.

gl4es is what makes the request answerable, and it wants the opposite of what
the renderer believes it is getting: desktop GL 1.x presented upwards, OpenGL ES
underneath. So the fixed function branch now asks for ES 3.0 and then ES 2.0,
and the renderer is never told - it reads its version through gl4es, which
reports desktop GL, and takes the desktop fixed function path on the strength of
it.

That got the renderer up, and revealed the second problem: the OpenXR session
found no current EGL context. Which is where gl4es having its own opinion about
glGetString stops being convenient. The engine's proof that a context is live is
GL_VERSION coming back non-NULL, and gl4es answers VERSION, VENDOR and RENDERER
out of constants without asking the driver - so a context that was created but
never bound passes that test and is only noticed much later, somewhere else
entirely. GLimp_SetMode now asks SDL directly whether the context it just made
is current, restoring the check gl4es defeated.

The session no longer assumes one is bound either. Being current is per thread,
and a window that has not been shown yet has no surface to be current against,
so VR_CreateSession asks for the binding through GLimp_MakeCurrent rather than
requiring it to already be there, and reports the EGL handles when it cannot get
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:02:44 -07:00
bearstonemandClaude Opus 5 d2d52a5af4 fix(vr): tell the renderer before moving the framebuffer under it
The VR layer bound each eye, the UI buffer and the wrist panel directly and
then told the renderer afterwards. That order was harmless while the renderer
issued every call as it made it. It is not harmless under gl4es, which batches
geometry and flushes lazily: it only learns to flush when something it knows
about changes the target, so a batch left over from one pass would arrive in
whatever was bound next - the previous eye's contents showing up in this one.

Routing the transition through re.SetDefaultFramebuffer first gives gl4es the
chance to flush into the target the geometry was drawn for. The direct bind
stays where it was needed, since the renderer only binds framebuffers when its
own framebuffer support is on; it is now a no-op against the same target.

Also points the Android docs at renderergl1 with gl4es under it, and records
that libgl4es.so rides into the APK as a dependency of libopenmohaa.so rather
than being loaded by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:46:20 -07:00
bearstonemandClaude Opus 5 6f34f4524c feat(vr): put the headset behind the fixed function renderer
Everything the VR work had built so far lived in renderergl2. This carries it
across to renderergl1, which is the renderer the port is moving to.

Most of it transfers unchanged: the head pose composed onto whatever camera the
game asked for (R_ApplyVRView, in RE_RenderScene so it follows cutscenes and
ladders as well as the player), the asymmetric per-eye frustum and vr_fovZoom in
R_SetupProjection, the eye render resolution asked for through
GetVRRenderResolution rather than r_mode, and the sprite paths that face the
viewer's position rather than the view plane.

Two places came out simpler than rend2. Everything 2D in this renderer goes
through Set2DWindow - RB_SetGL2D is a one line call to it - so the HUD's
convergence has a single place to be applied rather than two that had to be
kept in agreement. And RE_SetDefaultFramebuffer is nearly nothing: renderergl1
predates framebuffer objects, has none of its own and never touches the
binding, so redirecting an eye is one bind with no FBO_Bind to intercept and
nothing to restore.

glBindFramebuffer is resolved on first use rather than declared with the rest
of qgl, because renderergl1 has no extension probe of its own - it goes through
GLimp_GetProcAddress so it reaches gl4es like everything else the renderer
calls, and gl4es stays aware of which framebuffer is bound.

renderergl2 still builds and is unchanged in behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:38:59 -07:00
bearstonemandClaude Opus 5 633221c57d build(vr): actually build renderergl1 on Android, and route its GL through gl4es
The gl4es work was in the tree but nothing had exercised it. Three things were
wrong, each hiding the next.

cmake/platforms/android.cmake forced BUILD_RENDERER_GL1 OFF and GL2 ON into the
cache. CACHE INTERNAL implies FORCE, so the -D on the command line was
overwritten on every configure and renderergl1 had never once compiled - the
build that appeared to succeed was a renderergl2 build with gl4es sitting unused
beside it. The renderer choice goes back to the top level options, which already
default the way this port wants.

gl4es's own CMakeLists points CMAKE_LIBRARY_OUTPUT_DIRECTORY at
${CMAKE_SOURCE_DIR}/lib, which under FetchContent is this project's source root,
so the artifact was written into the engine's source tree. It also builds itself
as libGL.so.1 after the fashion of a system OpenGL, and Android has no versioned
sonames - the package manager extracts only lib*.so out of an APK. Redirected
into the build tree as libgl4es.so and staged with the other APK libraries.

With that fixed renderergl1 does compile for arm64, but it would not have
rendered. Its entry points come from GLimp_GetProcAddresses via
SDL_GL_GetProcAddress, and on EGL 1.5 - which the Quest is - SDL asks
eglGetProcAddress first. The driver answers for every name OpenGL 1.x and ES
have in common, so glEnable, glBindTexture and eighty more would have bound to
Adreno while glBegin and glMatrixMode bound to gl4es. gl4es would then have been
batching geometry and tracking a matrix stack against calls the driver was never
told about. The renderer now resolves against the gl4es handle directly, and
calls initialize_gl4es since NO_INIT_CONSTRUCTOR is set. The extension string
had the same problem and the same fix: read through SDL it is the driver's ES
list, which names none of the desktop extensions the renderer asks after, so
GL_ARB_multitexture read as absent and the renderer would have quietly dropped
to one texture unit.

The VR layer keeps its own direct GLES linkage; only the renderer is moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:26:22 -07:00
bearstonemandClaude Opus 5 7872929f32 feat(vr): head aim, stick locomotion and comfort options
The game now knows where the player is looking. Until now head tracking
only moved the camera the renderer drew from, so anything consulting the
player's view angles - objectives that ask them to look at something, aim,
hit detection - never saw it. CL_VRMove writes the head's heading and
pitch into cl.viewangles, which is what all of that reads.

Walking follows the off hand rather than the gaze, after the approach in
Team Beef's RTCWQuest. Tying travel to where the player is looking means
they cannot glance sideways without veering, which is what makes room
scale movement feel like being dragged; steering with the free hand lets
them look around and still walk a straight line. cl_vrWalkWithView
restores gaze-directed movement.

Turning snaps by default. A stick that rotates the world smoothly while
the inner ear insists otherwise is the usual way to make people ill, and
one flick gives one step rather than one per frame.

  cl_vrSnapAngle    45    degrees per flick
  cl_vrSmoothTurn   0     1 for smooth turning instead
  cl_vrTurnSpeed    90    degrees/second when smooth
  cl_vrDeadzone     0.2
  cl_vrWalkWithView 0     1 to walk where the player looks

All archived, so they are tunable on the headset rather than at build
time, which is what section 5 of the porting plan asks for.

Thumbsticks are bound on the Touch profile only: the simple controller
has none, and offering a binding a runtime does not recognise makes it
reject the whole set, which would cost the fallback its pointer too.

The dominant hand's aim angles are carried through vrInput_t but not yet
used. Aim is still on the head, which section 5 is right to call wrong
for a shooter - that belongs with weapon handling, and looking at things
had to work first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:21:14 -07:00
bearstonemandClaude Opus 5 393b0e3e3d fix(vr): make billboards face the viewer rather than the view plane
Quake's autosprites and MOH:AA's sprite imposters both orient themselves
by adopting the camera's own axes, so every one of them sits parallel to
the view plane. On a monitor that is indistinguishable from facing the
viewer, because the screen is the view plane. In a headset it is not:
turning the head swings every billboard in the level round in unison,
and they visibly spin in place.

They now take the direction from their own position to where the viewer
actually is, built against world up so they stay upright instead of
inheriting the head's roll. Head rotation no longer affects them; only
moving does.

Covers AutospriteDeform, and SPRITE_PARALLEL and SPRITE_PARALLEL_UPRIGHT
in the sprite path - the latter being the foliage imposters that stand in
for static models at a distance, which is why near foliage looked correct
while distant foliage did not.

Still outstanding: some foliage continues to rotate, so at least one more
path orients this way. Left for when movement makes it easier to tell
which effects are misbehaving and why. Autosprite2Deform is untouched: it
pivots around a fixed axis for beams and rails, so it is far less
sensitive to this, and changing it blind would risk breaking effects that
currently look right.

All of this is conditional on a VR view being active, so the flat
renderer is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:12:26 -07:00
bearstonemandClaude Opus 5 5aff8d336a feat(vr): stereo rendering and 6DoF head tracking through OpenXR
Runs on a Meta Quest 3 in stereo at the headset's native 1680x1760 per
eye, with the world tracking head movement in six degrees of freedom.

The session binds to the EGL context SDL already created rather than a
private one, so there is a single GL context and the renderer needs no
thread or context juggling. Each eye is rendered into an OpenXR swapchain
image by substituting the framebuffer the renderer treats as the screen,
which leaves the whole render path untouched. Exactly two eye passes per
frame; there is deliberately no third pass to produce a flat image.

The session and its swapchains belong to the GL context they were made
with, so they are torn down and rebuilt around the renderer restarts the
engine performs.

Menus, the loading screen and cinematics go on a quad layer fixed in the
room, placed once in front of wherever the player is facing. Drawing
screen space content into both eyes instead gives it no stereo disparity
while each eye still warps it through its own asymmetric frustum, and the
result cannot be fused. The panel is drawn into one persistent buffer
that is then copied into the swapchain image: a swapchain rotates through
several images, and the engine's 2D path expects the screen to still hold
what it drew last frame.

Head tracking:

- The per-eye asymmetric frustum from xrLocateViews feeds
  R_SetupProjection directly. A symmetric approximation misaligns the two
  images by a few degrees, which the eyes cannot fuse.
- The pose is applied in RE_RenderScene, so it follows every camera the
  game has - the player's, cutscenes, ladders, death animations - without
  each needing to know about VR.
- Orientation is carried as axes rather than pitch/yaw/roll, and composed
  with the game's heading as a rotation. Adding Euler angles only works
  while pitch and roll are near zero and turns every head movement into a
  twist once they are not.
- Position is measured from where the head was when tracking started, so
  the game keeps supplying eye height and the headset contributes only
  leaning, ducking and stepping. The initial heading is subtracted too,
  or the play space's arbitrary forward is added to the character's and
  the player spawns facing the wrong way.

The controllers point at the panel and click: the aim ray is intersected
with the quad and the hit becomes the engine's cursor position, so the
menus behave as they always did.

The renderer's own framebuffer objects stay off on Android. Each eye is
already a swapchain image, so routing through tr.renderFbo first would
cost a second full resolution pass and resolve per eye for nothing.

Known gaps: head orientation does not yet reach the player's view angles,
so the game does not know where the player is looking; and there is no
controller model or pointer ray.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:03:38 -07:00
bearstonemandClaude Opus 5 eea9112be9 feat(android): add Android platform support
Builds an APK for arm64-v8a, brought up on a Meta Quest 3 but not
specific to it. The engine reaches the main menu with retail data
side-loaded and renders through renderergl2 on an OpenGL ES 3.2 context.

Build system: a new cmake/platforms/android.cmake, driven by the NDK's
android.toolchain.cmake. renderergl1 is not built (it is fixed function
and would need a GL-over-GLES translation layer), the renderer is linked
into the client rather than dlopen'd, and SDL2 and OpenAL Soft are built
from source since neither ships an Android binary. OpenAL is not
optional here: it is the engine's only sound driver, so USE_OPENAL=OFF
does not build. Native libraries are staged per-ABI for gradle, which
packages them in misc/android.

Portability fixes for bionic:

- pthread_cancel does not exist; GameSpy's async DNS thread detaches
  instead.
- <execinfo.h> does not exist. sys_android_new.c walks the stack with
  _Unwind_Backtrace and resolves it through dladdr, writing straight to
  logcat. This matters because tombstoned does not reliably report a
  backtrace, leaving no account of where the engine died.
- librt does not exist; those symbols live in libc.
- libmumblelink.c needs shm_open and was being compiled even with
  USE_MUMBLE off. It now builds only when Mumble is enabled.

con_android.c sends the console to logcat and pumps stdout and stderr
there too, so output from the C library and from third party code is not
silently discarded.

The fatal signal handlers move ahead of Com_Init. Startup is exactly
when renderer and filesystem bring-up crash on a new platform, and a
fault there previously died with no backtrace at all.

Game modules ship inside the APK as libgame.so and libcgame.so and are
loaded off the linker's search path. Static linking is not an option:
cgame and fgame both compile q_shared, q_math and the script system, so
linking them into one binary collides. Android extracts only lib*.so
from an APK, hence DLL_PREFIX in q_platform.h.

Paths point at the app's external files directory, which is where the
user side-loads the game data and the only place the engine can write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:19:30 -07:00
bearstonemandClaude Opus 5 f3ca623053 fix(renderer): let renderergl2 initialise on OpenGL ES contexts
GLimp_GetProcAddresses() failed on every OpenGL ES context, so the GL2
renderer could not start on any ES target. Two independent causes:

QGL_1_1_PROCS carried five entry points that exist in no version of
OpenGL ES - glCallList, glDrawPixels, glFogi, glLineStipple and
glPixelZoom. That list is shared by the desktop and ES paths, so
requiring them there failed the whole load. Upstream keeps ES-incompatible
functions in QGL_DESKTOP_1_1_PROCS; these are moved there to match. Only
renderergl1 calls them - every use in renderergl2 sits inside an
"#if 0 // FIXME: unimplemented (GL2)" - so nothing loses a function it
actually invokes.

The ES 3.0 context was also only ever requested under __EMSCRIPTEN__.
Everywhere else the highest ES context asked for was 2.0, and since EGL
returns a genuine ES 2.0 context when that is what was requested, ES 3
was never reached even on hardware that supports it. It is now requested
first on all platforms.

Verified on a Meta Quest 3 (Adreno 740): renderergl2 now comes up on an
ES 3.2 context and compiles all 115 GLSL shaders. Desktop builds of both
renderers are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:19:10 -07:00
smallmodel 667c50f9b2 fix(cgame): draw svlag only if developer mode is enabled 2026-04-13 22:10:46 +02:00
smallmodel 7831195b98 feat(fgame): add cvar to allow standing on another player's head 2026-04-12 16:42:21 +02:00
smallmodel 44ce0595c2 fix(fgame): fix typo in TurretGun setthread response method (#915) 2026-04-09 00:37:09 +02:00
smallmodel 2a105f52a3 fix(fgame): use the default player start when no spawn point is specified during map load 2026-04-08 22:52:26 +02:00
smallmodel 067302c108 fix(fgame): fix incorrect damage clamping 2026-04-07 19:21:48 +02:00
smallmodel 614f424105 feat(fgame): add a default targetname to playerstart (#913) 2026-04-06 17:22:20 +02:00
smallmodel b1a45c0546 feat(client): add fps_location to set the location of the FPS counter on screen 2026-04-06 14:53:53 +02:00
smallmodel 8b9881736c feat(client): for fps, show the number of verts/tris when the option is set to 2 2026-04-06 13:46:38 +02:00
smallmodel 21a6e07180 fix(sys): use the binary's location as the default base path rather than the current directory
This is the default behavior in upstream (ioquake3).

This avoids issues with the current directory varying depending on how the program is launched (shortcut, services...) and improves portability when the binary is moved.

It doesn't require users to cd into a specific directory before launching.

The game directory can be overridden by setting `fs_basepath` variable.
2026-04-06 02:19:18 +02:00
smallmodel a21732ce3e chore(misc): update icon files
This updates/adds win32 icons for AA, SH and BT. This should fix corrupted icon on the taskbar.
2026-04-06 02:02:20 +02:00
smallmodel 5ebf885a19 chore(qcommon): handle com_viewlog modification 2026-04-06 01:16:22 +02:00
smallmodel 8c31216b59 feat(fgame): add an option for continuous legs animation 2026-04-06 00:29:41 +02:00
smallmodel d50de3f00a feat(fgame): add a variable to reopen doors from the opposite side when blocked 2026-04-05 21:41:24 +02:00
.Crimewavez 969311274f fix(cgame): crosshair centering at hi-res UI scaling (#908) 2026-03-29 21:41:05 +02:00
.Crimewavez 919206af63 fix(ui): use actual drawn width for horizontal statbar tile shader (#906) 2026-03-29 21:32:41 +02:00