Files
lhns--steam-frame-nix/modules/pet/package.nix
T
Pierre Kisters 4034fd4ded VR pet: a cat or dog in SteamVR's scene (steamFrame.pet)
A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the
windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund,
plus models from steamFrame.pet.extraModels. It walks around, follows,
sits, lies and sleeps, can be picked up by its grip bar, petted and
switched in its menu; its spot, pose and model are saved state.

Also the vr-pet command, "Pet" in the "+" menu with the current model's
icon (steam-frame-nix-pet-icon), the flake outputs pet-models,
pet-icons and pet-preview, and the check pet. A built-in model with
"private": true fails evaluation; private models belong in extraModels.
2026-10-01 04:33:39 +02:00

296 lines
16 KiB
Nix

# VR pet build: the cat's baked frames (bake.py, glTF helpers in rig.py),
# the model catalog (index.py: models/<id>/ and extraModels; coats by
# skin.py, animals by bake_gltf.py, thumbnails by thumb.py), the SteamVR
# patch (core.js + systemui.js), the `vr-pet` CLI (cli.mjs), the "+" menu
# entry and its icons (steam-frame-nix-pet-icon: icon.sh), the desktop preview (preview/)
# and the tests (tests/, the flake check pet). System-independent
# inputs (pinned fetchurl, Python + numpy/pillow), so it also builds on
# x86_64. Credits and licences: README.md, "Credits".
{ pkgs, lib ? pkgs.lib
, height ? 0.30 # m, standing height of the cat (ears)
, fps ? 24 # baked frames per second, and core ticks per second
, catOptions ? { } # core.js options (DEFAULTS there)
, mount ? "dynamic" # systemui.js: "dynamic" | "all"
, finders ? null # steam-frame-nix's lib/finders.js and lib/hooks.js
, hooks ? null # (for the SteamVR patch)
, extraModels ? { } # id -> a model folder (as models/<id>/) or a spec (attrset; files as paths)
, defaultModel ? null # the model shown first (null: the spec with "default": true)
, runtimeDir ? "/run/user/1000" # steam-frame-nix-pet-icon: its link's tmpfs (<runtimeDir>/steam-frame-nix/vr-pet)
, stateFile ? "/nonexistent" # steam-frame-nix-pet-icon: the patch's state file (its "model")
, hicolor ? "/nonexistent" # steam-frame-nix-pet-icon: the hicolor dir whose mtime it bumps
}:
let
toonCat = pkgs.fetchurl {
name = "toon_cat_free.glb"; # Omabuarts, CC-BY 4.0
url = "https://raw.githubusercontent.com/DevTakao/threejs-cat/97b71575ebd84ff32c50785563bb6b97a9b87012/assets/models/toon_cat_free.glb";
hash = "sha256-92KR9dN9YPxY6ABqERGllglYVF/E2VkpiGgq06mq74E=";
};
tuxedo = "https://raw.githubusercontent.com/xialin-he/xialin-he.github.io/054a1f088cdd19544a8fd5d6d5eaad11e8b0d08e/public/tuxedo_cat_animated_2.0";
tuxedoGltf = pkgs.fetchurl { # DreamNoms, CC-BY 4.0
name = "tuxedo-scene.gltf";
url = "${tuxedo}/scene.gltf";
hash = "sha256-BLwRrWZL65yEKE+4pOYw3lrun8ZUrw5HqeM3ol96HoI=";
};
tuxedoBin = pkgs.fetchurl {
name = "tuxedo-scene.bin";
url = "${tuxedo}/scene.bin";
hash = "sha256-iiRZRV2GxilBDUvz6tUAzT3z5+Vqr/AkzQ6wFZFfTqw=";
};
threeTgz = pkgs.fetchurl { # MIT
url = "https://registry.npmjs.org/three/-/three-0.170.0.tgz";
hash = "sha256-SmCKNV3KunLg5Tg83IFDA/W2BgtDwjjN9pMtzraZI40=";
};
python = pkgs.python3.withPackages (p: [ p.numpy p.pillow ]);
# Copies these files of this folder into src/ (a step's scripts import each
# other; only the files it runs, so other edits don't rebuild it).
srcOf = names: "mkdir -p src\n" + lib.concatMapStrings (n: "cp ${./. + "/${n}"} src/${n}\n") names;
models = pkgs.runCommand "vr-pet-models" { nativeBuildInputs = [ python ]; } ''
mkdir tuxedo
cp ${tuxedoGltf} tuxedo/scene.gltf
cp ${tuxedoBin} tuxedo/scene.bin
${srcOf [ "bake.py" "rig.py" ]}
python3 src/bake.py ${toonCat} tuxedo/scene.gltf $out ${toString height} ${toString fps}
cp ${credits} $out/CREDITS.md
'';
# Next to the baked frames (derived from CC-BY 4.0 models); the full
# attributions: README.md, "Credits".
credits = pkgs.writeText "CREDITS.md" ''
Based on "Toon Cat FREE" by Omabuarts Studio
(https://sketchfab.com/3d-models/toon-cat-free-b2bd1ee7858444bda366110a2d960386)
and "Tuxedo Cat Animated 2.0" by DreamNoms
(https://sketchfab.com/3d-models/tuxedo-cat-animated-20-783fcb78b55b4394a212c2b6392e1113),
licensed under CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/);
modified: retargeted and hand-keyed animations, baked per frame.
Credits: https://github.com/lhns/steam-frame-nix#credits
'';
# The model catalog: one folder per model, models/<id>/model.json (and the
# files it names), found here by readDir (no list of ids anywhere), plus
# extraModels (a folder like those, or a spec as an attrset); built by
# index.py into one directory per model and models.json (the index the
# patch embeds). The kinds and fields: docs/pet-models.md. An animal's bake
# is a derivation of its own (its spec, its glTF, bake_gltf.py, rig.py), so
# editing one model's spec rebakes only that model (and those extending it).
specError = id: msg: throw "vr-pet model ${id}: ${msg} (see docs/pet-models.md)";
# id -> folder -> { id, spec, file: the spec's store path, at: a file of the folder }
readModel = id: dir:
let f = dir + "/model.json"; in
if !(builtins.pathExists f) then specError id "no model.json in ${toString dir}"
else { inherit id; spec = builtins.fromJSON (builtins.readFile f); file = f; at = name: dir + "/${name}"; };
discover = root: lib.mapAttrsToList (id: _: readModel id (root + "/${id}"))
(lib.filterAttrs (_: t: t == "directory") (builtins.readDir root));
# The built-in models (models/<id>/) are published with steam-frame-nix:
# one whose spec says "private": true (a character someone else owns,
# personal use only) fails evaluation. Private models go into extraModels,
# from the user's own configuration.
builtinOf = root: let
ms = discover root;
private = map (m: m.id) (lib.filter (m: m.spec.private or false == true) ms);
in if private != [ ] then throw ("vr-pet: built-in model(s) ${lib.concatStringsSep ", " private} say \"private\": true;"
+ " private models belong in steamFrame.pet.extraModels of your own configuration,"
+ " never in modules/pet/models/ (docs/pet-models.md, \"Private models\")")
else ms;
extra = lib.mapAttrsToList (id: m:
if builtins.isPath m || builtins.isString m then readModel id m
else let spec = { kind = "texture"; order = 100; } // m; in
{ inherit id spec; file = pkgs.writeText "${id}-model.json" (builtins.toJSON spec); at = p: p; }) extraModels;
# "extends" (docs/pet-models.md): the other spec with this one's fields on
# top, an object field entry by entry; files looked up here, then there.
extend = all: m:
if !(m.spec ? extends) then m else
let
b = extend all (lib.findFirst (x: x.id == m.spec.extends)
(specError m.id "\"extends\": no model ${builtins.toJSON m.spec.extends}") all);
over = n: v: if builtins.isAttrs v && builtins.isAttrs (b.spec.${n} or null) then b.spec.${n} // v else v;
spec = builtins.removeAttrs (builtins.removeAttrs b.spec [ "default" "thumb" ] // lib.mapAttrs over m.spec) [ "extends" ];
in {
inherit (m) id;
inherit spec;
file = pkgs.writeText "${m.id}-model.json" (builtins.toJSON spec);
at = name: if builtins.pathExists (m.at name) then m.at name else b.at name;
};
extendAll = all: map (extend all) all;
# A spec with its files as store paths (index.py checks the rest).
resolve = { id, spec, file, at }:
let kind = spec.kind or (specError id "no \"kind\""); in
spec // { inherit id; } // (
if kind == "texture" then { png = "${at (spec.png or (specError id "kind texture needs \"png\""))}"; }
else if kind == "gltf" then { baked = "${bakeGltf id file (spec.src or (specError id "kind gltf needs \"src\"")) at}"; }
else { }) // lib.optionalAttrs (spec ? thumb) { thumb = "${at spec.thumb}"; };
# An animal's frames: bake_gltf.py on its glTF (gltfOf).
bakeGltf = id: file: src: at:
let glb = gltfOf id src at; in
pkgs.runCommand "vr-pet-${id}" { nativeBuildInputs = [ python ]; } ''
${srcOf [ "bake_gltf.py" "rig.py" ]}
python3 src/bake_gltf.py ${file} ${glb} $out ${toString fps}
'';
# An animal's glTF from its "src" (docs/pet-models.md): a file of its
# folder, {url, hash} (fetched) or {path}; optionally unzipped ("unzip",
# a fetched one's hash is of the unpacked files) and turned into a glTF by
# a Blender script ("blend", "blender": a derivation of its own, so it
# reruns only when its source or script changes).
gltfOf = id: src: at:
if !(builtins.isAttrs src) then at src
else let
zip = src ? unzip;
unzip = ''${pkgs.unzip}/bin/unzip -q "$zip" ${lib.escapeShellArg (src.unzip or "")} -d "$out" || [ $? -le 1 ]'';
got = if src ? path then at src.path
else pkgs.fetchurl ({ name = if zip || src ? blender then "${id}-src" else "${id}.glb"; inherit (src) url hash; }
// lib.optionalAttrs zip { downloadToTemp = true; recursiveHash = true; postFetch = "zip=$downloadedFile; ${unzip}"; });
unpacked = if zip && src ? path then pkgs.runCommand "${id}-src" { } "zip=${got}; ${unzip}" else got;
input = if src ? blend then "${unpacked}/${src.blend}" else unpacked;
in if src ? blender then
pkgs.runCommand "${id}.glb" { nativeBuildInputs = [ pkgs.blender ]; } ''
export HOME=$TMPDIR
blender -b ${lib.escapeShellArg input} --python-exit-code 1 --python ${at src.blender} -- $out
test -s $out
''
else input;
specOf = specs: pkgs.writeText "vr-pet-models-spec.json" (builtins.toJSON { default = defaultModel; models = map resolve specs; });
catalogOf = specs: let
spec = specOf specs;
in pkgs.runCommand "vr-pet-catalog" { nativeBuildInputs = [ python ]; } ''
${srcOf [ "index.py" "skin.py" "thumb.py" ]}
python3 src/index.py ${spec} ${models} $out
'';
specs = extendAll (builtinOf ./models ++ extra);
catalog = catalogOf specs;
# (tests: the same with the model folders of tests/models added, as
# extraModels: a coat and a private one)
testCatalog = catalogOf (extendAll (builtinOf ./models ++ extra ++ discover ./tests/models));
# (tests: a private built-in model, tests/builtin-private, is refused)
privateRefused = !(builtins.tryEval (builtinOf ./tests/builtin-private)).success;
options = {
inherit fps mount;
cat = catOptions;
};
# ((opts, catalog, find, hooks) => { core; adapter; return vrPetSystemui(…); })
# (opts, models.json, finders.js, hooks.js)
patch = assert finders != null && hooks != null; pkgs.runCommand "vr-pet.js" { } ''
{ echo '((opts, baked, find, hooks) => {'
cat ${./core.js} ${./systemui.js}
echo 'return vrPetSystemui(opts, baked, find, hooks);'
echo '})('
echo ${lib.escapeShellArg (builtins.toJSON options)}
echo ','
cat ${catalog}/models.json
echo ','
cat ${finders}
echo ','
cat ${hooks}
echo ')'; } > $out
'';
# The "+" menu entry's icons: <id>.png for every model of the catalog (its
# thumbnail's picture, 256 px, transparent) and default.txt (the default
# model's id); steam-frame-nix-pet-icon links the current model's.
icons = pkgs.runCommand "vr-pet-icons" { nativeBuildInputs = [ python ]; } ''
${srcOf [ "index.py" "skin.py" "thumb.py" ]}
python3 src/index.py --icons ${specOf specs} ${catalog} $out
'';
# steam-frame-nix-pet-icon (icon.sh): points <runtimeDir>/steam-frame-nix/vr-pet/icon.png at the icon
# of the model in the patch's state file (stateFile); bumps hicolor's
# mtime when it changed. All four overridable by environment (the tests).
iconLink = pkgs.writeShellApplication {
name = "steam-frame-nix-pet-icon";
runtimeInputs = [ pkgs.coreutils pkgs.jq pkgs.util-linux ];
text = ''
runtime=''${VR_PET_RUNTIME_DIR:-${lib.escapeShellArg runtimeDir}}
state=''${VR_PET_STATE:-${lib.escapeShellArg stateFile}}
icons=''${VR_PET_ICONS:-${icons}}
hicolor=''${VR_PET_HICOLOR:-${lib.escapeShellArg hicolor}}
'' + builtins.readFile ./icon.sh;
};
# The "+" menu entry: shows the pet (never hides it; the launcher's
# debounce keeps a double click from launching twice); its icon is
# steam-frame-nix-pet-icon's link.
desktopEntry = pkgs.writeText "vr-pet.desktop" ''
[Desktop Entry]
Type=Application
Name=Pet
Comment=Bring the VR pet back
Exec=${cli}/bin/vr-pet show
Icon=vr-pet
Terminal=false
Categories=Game;Amusement;
'';
# vr-pet show [--summon] | hide | status | models | model <id> (cli.mjs). Also run by Steam's
# "+" menu, hence no inherited LD_PRELOAD / LD_LIBRARY_PATH; its output also
# goes to the journal (vr-pet).
cli = pkgs.writeShellApplication {
name = "vr-pet";
runtimeInputs = [ pkgs.nodejs pkgs.systemd ];
text = ''
unset LD_PRELOAD LD_LIBRARY_PATH
rc=0
out=$(node ${./cli.mjs} "$@" 2>&1) || rc=$?
if [[ -n $out ]]; then printf '%s\n' "$out"; fi
{ printf 'vr-pet %s: ' "$*"; printf '%s\n' "''${out:-(no output)} (exit $rc)"; } | systemd-cat -t vr-pet 2>/dev/null || true
exit "$rc"
'';
};
three = pkgs.runCommand "three-0.170.0" { } ''
mkdir $out
tar -xzf ${threeTgz} --strip-components=1 -C $out package/build/three.module.js package/LICENSE
mv $out/build/three.module.js $out/
rmdir $out/build
'';
site = pkgs.runCommand "vr-pet-preview-site" { } ''
mkdir $out
cp ${./preview/index.html} $out/index.html
cp ${./preview/preview.js} $out/preview.js
cp ${./core.js} $out/core.js
echo ${lib.escapeShellArg (builtins.toJSON { inherit fps; cat = catOptions; })} > $out/config.json
ln -s ${models} $out/models
ln -s ${catalog} $out/catalog
ln -s ${three} $out/three
'';
# Headless tests (flake check `pet`): the core with scripted hands
# (core, species: every frame set of the catalog), the SteamVR adapter in a
# fake systemui page (systemui), the CLI with a fake DevTools transport,
# the catalog (models), the glTF reshaping (gltf), the "+" menu's icons,
# entry and steam-frame-nix-pet-icon (icons, entry, icon link), the
# refusal of a private built-in model (private).
tests = pkgs.runCommand "pet-tests" { nativeBuildInputs = [ pkgs.nodejs python pkgs.desktop-file-utils iconLink ]; } ''
mkdir -p src/tests
${srcOf [ "core.js" "systemui.js" "unpatch.js" "cli.mjs" "skin.py" "index.py" "thumb.py" "bake_gltf.py" "rig.py"
"tests/sim.js" "tests/core.test.js" "tests/species.test.js" "tests/systemui.test.js" "tests/cli.test.mjs"
"tests/models.test.py" "tests/gltf.test.py" ]}
cp -r ${./models} src/models
{ echo '# core'; node src/tests/core.test.js ${models}/frames.json; } > core.log || { cat core.log; exit 1; }
{ echo '# species'; node src/tests/species.test.js ${catalog}/models.json; } > species.log || { cat species.log; exit 1; }
{ echo '# systemui'; node src/tests/systemui.test.js ${catalog}/models.json; } > systemui.log || { cat systemui.log; exit 1; }
{ echo '# cli'; node src/tests/cli.test.mjs; } > cli.log || { cat cli.log; exit 1; }
{ echo '# models'; python3 src/tests/models.test.py ${catalog} ${models} ${testCatalog}; } > models.log || { cat models.log; exit 1; }
{ echo '# gltf'; python3 src/tests/gltf.test.py; } > gltf.log || { cat gltf.log; exit 1; }
{ echo '# icons'; python3 ${./tests/icons.test.py} ${./models} ${icons}; } > icons.log || { cat icons.log; exit 1; }
{ echo '# entry'; desktop-file-validate ${desktopEntry} && echo 'ok desktop-file-validate vr-pet.desktop'; } > entry.log 2>&1 || { cat entry.log; exit 1; }
{ echo '# icon link'; bash ${./tests/icon.test.sh}; } > link.log 2>&1 || { cat link.log; exit 1; }
{ echo '# private'; ${if privateRefused then "echo 'ok a private built-in model (tests/builtin-private) fails evaluation'"
else "echo 'FAIL a private built-in model (tests/builtin-private) was not refused'; exit 1"}; } > private.log || { cat private.log; exit 1; }
cat core.log species.log systemui.log cli.log models.log gltf.log icons.log entry.log link.log private.log | tee $out
'';
preview = pkgs.writeShellApplication {
name = "vr-pet-preview";
runtimeInputs = [ pkgs.python3 ];
text = ''
port="''${1:-8765}"
echo "VR pet preview: http://127.0.0.1:$port/ (Ctrl+C stops)"
exec python3 -m http.server --bind 127.0.0.1 --directory ${site} "$port"
'';
};
in {
inherit models catalog patch site preview tests icons iconLink desktopEntry cli;
}