thu.ndero.us Experiments in engine, systems, and iteration
Learning

An evening unable to walk

I spent an evening unable to walk. The plan was straightforward: I'd added a projectile effect to the skeleton mages, and I wanted to know what it cost. That means a device build and a frame-cost sweep — nine phases, one…

I spent an evening unable to walk.

The plan was straightforward: I'd added a projectile effect to the skeleton mages, and I wanted to know what it cost. That means a device build and a frame-cost sweep — nine phases, one view held for three minutes, the wearer standing still while the scene turns things on and off around them. I built the APK, installed it, put the headset on, and couldn't move.

I could turn. I could open the settings menu. The A button worked. The left thumbstick did
nothing at all.

What it looked like

The first thing I noticed was that my left controller had disconnected earlier and I'd replaced the batteries. That's a tidy story: controller missing at launch, rig binds without it, reconnecting doesn't help. We force-stopped the app for a clean launch. Still couldn't move.

So we went to the logs, and the logs were full of plausible things. The OpenXR runtime had
reclassified both controllers as "detached" twenty-seven seconds into the session and switched the whole thing to hand tracking, with multimodality off — hands and controllers mutually exclusive. That explains everything, except it doesn't: the left Menu button still opened the settings panel, which a hand-tracking session couldn't do. Dead end, and a good one, because it was killed by evidence rather than argument.

Then the headset's floor registration looked wrong — the app reported my eye at 0.84 m, which is nobody's standing height. Except I was sitting, and I sit low, which is why the seated setting exists. Consistent, not broken. Another dead end.

By the time we started instrumenting, we'd eliminated ten things: controller hardware, hand
tracking, interaction-profile binding, the hub spawn position, my own component that switches off ISDK's jump, managed code stripping, the settings in PlayerPrefs, the armed sweep itself, the layer table, and stance and height calibration. Every one of them had to be ruled out, and none of them was the cause.

The probe that lied

Since the operator tooling can't reach a standalone build, we built a probe into the APK that logs the live hierarchy. It reported that the left locomotion interactor group didn't exist at all — only the right one. That's a dramatic finding and I nearly acted on it.

It was wrong. The probe built its report by concatenating everything into one string, and Unity cut the line partway through a path. The truncated list read exactly like an absence. The only reason it got caught is that it contradicted a count logged four seconds earlier by a different component, which had found two of them.

The second lie was quieter. The probe also looked for the character capsule, to rule out being stuck in geometry, and found none — so I reported that the capsule theory was dead. But CharacterController in that file bound to Unity's type, while the rig carries ISDK's class of the same name. It compiled cleanly, matched nothing, and printed nothing. A check that emits no output is indistinguishable from a check that passed, and this one had never tested anything at all.

Both of those are the same defect as a bug I'd already written up months earlier about grepping Unity prefabs and getting confident false negatives. It just wore a different costume.

What it actually was

FirstPersonLocomotor, ISDK's movement component, does one thing in Start: it casts downward looking for ground within ten metres. If it misses, it calls DisableMovement() — permanently, a latch it never revisits.

It runs while the player is still at the world origin, before my code moves them to the hub,
which sits at x=500. Nothing stands under the origin when a run starts in the hub. The cast
missed, movement died, and one frame later the probe reported grounded=True — too late, the latch was set.

Three things make that invisible. Translation and gravity live behind the same flag, so the
player neither walks nor falls, which reads as being stuck rather than switched off. Rotation takes a completely separate path with no ground check, so turning kept working and the whole thing presented as a broken controller. And the input arrived correctly the entire time — velocity accumulated to 570 units and was simply never applied. Every check upstream passes, because everything upstream is genuinely fine.

The reason nothing said so is the part worth keeping. ISDK routes its diagnostics through
[Conditional("UNITY_ASSERTIONS")], which is defined in the Editor and in development builds and absent from a release player. So the warning explaining the failure gets compiled out, while the DisableMovement() call next to it still runs. We made one development build and it printed the answer verbatim: "The ground could not be found below the locomotor for 10 meters. Velocity will be disabled."

One build. After an evening.

Why it survived seven wearings

It only fails on device, and it's a race rather than a platform difference — over Link the
ordering resolves the other way. I'd walked to the vault and the portal over Link that same day. "Works on Link" was true and completely worthless.

And every previous device session was a frame-cost sweep, where the whole job is to stand still and hold one view. The one thing those runs never do is walk.

The fix is four lines. The part I care about is the constraint: the placement code now says out loud when it has to clear that latch, and there's a permanent watchdog that raises a warning whenever movement is disabled, explaining that turning will keep working and make it look like a controller fault. Quiet enough to leave running during the sweep.

I still haven't measured what the mage bolt costs. That's tomorrow.