cz imgui  / vr gorilla tag menu

about

cz imgui is a dear imgui based menu for gorilla tag. the menu lives on the player's left wrist as a textured quad. the right hand drives a cursor that is projected onto the panel; the right trigger acts as click. text is rasterized on the cpu through a software path and uploaded to a unity texture2d every frame.

two button binds: hold left b or left grip to show the menu while held.

build & inject

./gradlew :app:assembleRelease

output:

app/build/intermediates/stripped_native_libs/release/
  stripReleaseDebugSymbols/out/lib/arm64-v8a/libcz_imgui.so

drop into lib/arm64-v8a/ inside the gorilla tag apk, then add the following smali after onCreate (locals 2):

const-string v1, "cz_imgui"
invoke-static {v1}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V

repack, sign, sideload.

file layout

app/src/main/cpp/
  src/
    native-lib.cpp        // hooks, mods, ui
    czrender.cpp          // software triangle raster
  include/
    czrender.hpp
    XRInput.hpp / XRInput.cpp
    BNMResolve.hpp        // unity api wrappers
  extern/
    BNM-Android/
    Dobby/
    imgui/                // dear imgui v1.91.5

register a mod

two functions register a mod into the global registry. both take a category string, a mod name, and a lambda body. categories become collapsing headers in the menu; names become checkboxes inside.

addtick(const char* category, const char* name, void(*fn)())

the lambda runs every frame while the checkbox is on. use this for anything continuous: movement, force, polling input, applying a field repeatedly.

addoneshot(const char* category, const char* name, void(*fn)())

the lambda runs once on the rising edge (the frame the checkbox flips from off to on). use this for one-time setters like gravity, reset values, applying a preset.

where to put them

everything goes inside registermods() in native-lib.cpp. it is called once on first tick after hands are found.

static void registermods() {
    if (!g_mods.empty()) return;

    addtick("movement", "long arms", []() {
        gtag::setpf("maxArmLength", 999.0f);
    });

    addoneshot("gravity", "zero", []() {
        Physics::SetGravity(Vector3(0, 0, 0));
    });
}

tick vs oneshot

typefirestypical use
tickevery frame while onfly, speed force, holding a key, polling trigger
oneshotonce when toggled onset gravity, set jump multiplier, scale arms

oneshot does not undo itself when toggled off. add a paired oneshot ("fix gravity", "normal gravity", etc.) to restore.

gtag helpers

shortcuts for accessing the local gorillalocomotion.player singleton and common transforms. all helpers handle null silently.

void* gtag::player()

returns the player instance pointer, or nullptr if not in the scene yet.

void gtag::setpf(const char* field, float value) float gtag::getpf(const char* field)

read/write a float field by name. examples: "jumpMultiplier", "maxJumpSpeed", "maxArmLength", "velocityLimit", "defaultSlideFactor".

void gtag::setpb(const char* field, bool value)

set a bool field. e.g. "disableMovement", "wasLeftHandTouching".

void gtag::setpv3(const char* field, Vector3 value)

set a vector3 field. e.g. "rightHandOffset", "leftHandOffset".

Transform* gtag::head() Transform* gtag::body() Rigidbody* gtag::rb() Transform* gtag::roottrans()

head returns the head collider transform; body returns the body collider transform; rb returns playerRigidBody; roottrans finds the "GorillaPlayer" root transform.

xr input

three static methods. Controller::Left and Controller::Right select the hand.

bool XRInput::GetBoolFeature(BoolFeature feat, Controller con)

features: PrimaryButton, SecondaryButton, GripButton, TriggerButton, MenuButton, Primary2DAxisClick, Primary2DAxisTouch, Secondary2DAxisClick, Secondary2DAxisTouch, PrimaryTouch, SecondaryTouch.

float XRInput::GetFloatFeature(FloatFeature feat, Controller con)

features: Trigger, Grip. value in [0,1].

Vector2 XRInput::GetVector2Feature(Vector2Feature feat, Controller con)

features: Primary2DAxis, Secondary2DAxis. thumbstick coords.

examples

fly forward while holding right trigger

addtick("movement", "fly", []() {
    float t = XRInput::GetFloatFeature(Trigger, Right);
    if (t < 0.5f) return;
    Transform* h = gtag::head();
    Transform* r = gtag::roottrans();
    Rigidbody* body = gtag::rb();
    if (!h || !r) return;
    Vector3 step = h->GetForward() * Time::GetDeltaTime() * 12.0f;
    r->SetPosition(r->GetPosition() + step);
    if (body) body->SetVelocity(Vector3(0,0,0));
});

bounce on left trigger

addtick("movement", "bounce", []() {
    if (!XRInput::GetBoolFeature(TriggerButton, Left)) return;
    auto* body = gtag::rb();
    if (body) body->AddForce(Vector3(0, 3.5f, 0), ForceMode::VelocityChange);
});

preset speed boost (oneshot)

addoneshot("movement", "speed boost", []() {
    gtag::setpf("jumpMultiplier", 2.25f);
    gtag::setpf("maxJumpSpeed", 999.0f);
});

headless

addtick("body", "headless", []() {
    Transform* h = gtag::head();
    if (h) h->SetLocalScale(Vector3(0,0,0));
});

widgets

dear imgui's widget set works inside buildui(). anything you put there is rendered to the panel.

button

if (ImGui::Button("do thing", ImVec2(-1, 28))) {
    // fires once on click
}

-1 width = stretch to full panel width.

checkbox

static bool on = false;
ImGui::Checkbox("enable", &on);

note: the mod registry already builds checkboxes for every registered mod, so prefer addtick / addoneshot for actual mod state.

slider

static float v = 1.0f;
ImGui::SliderFloat("strength", &v, 0.1f, 5.0f, "%.2f");

collapsing header

if (ImGui::CollapsingHeader("category", ImGuiTreeNodeFlags_DefaultOpen)) {
    // widgets inside
}

separator and spacing

ImGui::Separator();
ImGui::Spacing();

color picker

static ImVec4 col(1,0,0,1);
ImGui::ColorEdit4("tint", (float*)&col);

text

plain text

ImGui::Text("%.0f fps", io.Framerate);
ImGui::TextUnformatted("static line");
ImGui::TextDisabled("%d players", n);
ImGui::TextColored(ImVec4(1,0.4f,0.4f,1), "danger");

colors

every helper takes four floats: (r, g, b, a), each in [0, 1]. alpha defaults to 1.0f when omitted.

helperaffects
czstyle::background(r,g,b,a)panel fill
czstyle::bordercolor(r,g,b,a)outline & separators
czstyle::titlecolor(r,g,b,a)title bar
czstyle::accent(r,g,b,a)hover/active highlights
czstyle::buttoncolor(r,g,b,a)button default fill
czstyle::framecolor(r,g,b,a)slider/checkbox background
czstyle::headercolor(r,g,b,a)collapsing header default
czstyle::textcolor(r,g,b,a)body text
czstyle::checkmark(r,g,b,a)check tick color

defaults in initimgui:

czstyle::background  (0.06f, 0.07f, 0.10f, 0.78f);
czstyle::bordercolor (0.35f, 0.55f, 0.95f, 0.55f);
czstyle::titlecolor  (0.15f, 0.40f, 0.85f, 0.95f);
czstyle::headercolor (0.18f, 0.30f, 0.55f, 0.55f);
czstyle::buttoncolor (0.20f, 0.24f, 0.32f, 0.85f);
czstyle::framecolor  (0.12f, 0.14f, 0.20f, 0.85f);
czstyle::accent      (0.30f, 0.55f, 0.95f, 0.95f);
czstyle::checkmark   (0.30f, 0.95f, 0.50f, 1.00f);
czstyle::textcolor   (0.95f, 0.96f, 1.00f, 1.00f);

sizes

czstyle::rounding(float px)

sets window, child, frame, popup, scrollbar, grab rounding from one value (proportional). 0 = sharp, 14 = default, 24+ = chunky.

czstyle::bordersize(float px)

outline thickness. 0 disables.

czstyle::padding(float x, float y)

inner padding inside windows. frame padding scales from this (0.85x, 0.6y).

czstyle::spacing(float x, float y)

spacing between widgets.

czstyle::fontscale(float scale)

multiplies all text size. 1.0 = native bitmap font (small), 1.4 = current default, 2.0 = large.

panel size in world

the physical panel is set by constants at the top of native-lib.cpp:

static constexpr float kpanelmetersw = 0.18f;  // width in meters
static constexpr float kpanelmetersh = 0.24f;  // height in meters

change and rebuild. the framebuffer resolution stays at 384x512 regardless.

panel offset from the left wrist and rotation are inside czstate:

Vector3    localoffset    = {0.0f, 0.10f, 0.04f};
Quaternion localrotoffset = Quaternion::FromEuler(65.0f, 0.0f, 0.0f);

FromEuler is bnm's order: (yaw, pitch, roll). positive yaw rotates around y; positive pitch is around x.


render pipeline

  1. dear imgui builds widgets on the cpu inside buildui().
  2. ImGui::Render produces a ImDrawData with triangle lists.
  3. cz::rasterizedrawdata walks each triangle, samples the font atlas, alpha-blends into a 384x512 rgba32 cpu framebuffer.
  4. rows are uploaded reversed (cpu y-flip) into a fresh managed byte[] per frame.
  5. LoadRawTextureData(byte[]) + Apply(false,false) commits to the gpu texture.
  6. the texture is sampled by a quad parented to the left hand controller; shader picked from a priority list, defaults to "Universal Render Pipeline/Unlit".

cpu cost per frame: clear framebuffer (~700kb memset) + rasterize triangles + memcpy + apply. easily fits inside a quest 2 frame budget at 384x512.

hooks & loading

entry: JNI_OnLoad registers onloaded with bnm and calls BNM::Loading::TryLoadByJNI. once il2cpp resolves, onloaded runs.

static void onloaded() {
    gcz.playercls = BNM::Class("GorillaLocomotion", "Player");
    auto cc = BNM::Class("GorillaNetworking", "CosmeticsController");
    auto update = cc.GetMethod("Update", 0);
    BNM::InvokeHook(update, hook_ccupdate, orig_ccupdate);
}

the hook on CosmeticsController::Update is chosen because the class is loaded from the very first scene; GorillaLocomotion.Player.Update only fires after a local player spawns.

every frame, tick():

  • finds LeftHand Controller and RightHand Controller via GameObject::Find
  • iterates g_mods and calls each enabled mod's lambda
  • polls the show/hide button (left b or left grip)
  • updates the panel transform from localoffset + localrotoffset
  • projects the right hand onto the panel plane to feed imgui mouse
  • renders the imgui frame, rasterizes, uploads

performance

optimizations applied:

  • incremental edge functions in the rasterizer. previously each pixel ran 3 edge() calls (9 multiplications + 6 subtractions just for barycentric weights) plus full uv/color interpolation (16 mults + 12 adds). new path computes per-edge dx/dy deltas once per triangle and advances the weights, uv, and color by simple additions per pixel. roughly 6x less math per covered pixel. this is the single biggest win and the one that prevents the 72 -> 42 fps drop.
  • row-based pixel loop hoists the row pointer into a local; per-pixel offset becomes a multiply by 4 instead of recomputing the full (py*fb.w + px)*4 index.
  • vertex color unpacked once per triangle (12 bytes -> 12 floats) instead of per pixel.
  • hand controller gameobjects cached for 600 frames between Find calls; refreshed periodically as a safety net for scene transitions.
  • mod categories built once at registration into g_modcats; buildui iterates that vector instead of rebuilding an std::map per frame.
  • rasterize + texture upload throttled to every other frame (effective ~36 hz at 72 fps panel update). imgui itself still polls every frame, so input latency stays ~14ms worst case.
  • upload skipped entirely when ImDrawData has zero vertices.
  • tick path early-exits when menu is closed; mods still tick but ui + rasterize + upload are skipped.

knobs you can tune in native-lib.cpp:

wherewhat it controls
kfbwidth, kfbheightframebuffer resolution (768kb at 384x512). drop to 256x384 to nearly halve cpu/bandwidth cost.
rasterskip = (rasterskip + 1) % 2change 2 to 3 for ~24hz updates. higher number = less work, more latency.
refind_cd = 600frames between hand-controller re-find. lower if you switch scenes often.