Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ The right stick also performs one punch after a center→peak→center gesture.

### Presentation and settings

The committed client renders a real-time 3D broadcast presentation with WebGL (pinned three.js): a procedurally modeled arena, canvas-textured ring, a referee, dynamic lighting and shadows, and GPU particle effects—plus generated Web Audio. The fighters are the CC0 KayKit Barbarian rig (Kay Lousberg, kaylousberg.com; license in web/hands/assets-src/) driven by authored clips retarget-baked from the project's own boxing motion (idle, movement, guards, all punches both hands, reactions, knockdown, get-up), phase-locked to the authoritative simulation via combat-manifest.json. All arena meshes, textures, and sounds remain generated in code. **Blood defaults to `full`**; Settings can select `full`, `reduced`, or `off` without changing authoritative trauma or HUD information. Settings also provide master audio volume, haptics on/off, and reduced motion. Audio unlocks only after user interaction and suspends while hidden; haptics are feature-detected and limited to bounded authoritative impact events. Reduced motion removes particles/shake while preserving match state and private get-up information.
The committed client renders a real-time 3D broadcast presentation with WebGL (pinned three.js): a procedurally modeled arena, canvas-textured ring, a referee, dynamic lighting and shadows, and GPU particle effects—including deliberately over-the-top, full-mode arcade gore—plus generated Web Audio. The fighters use the realistic “Boxer” rig by Texel, Inc. (CC BY 4.0; source, attribution, and modification notice in `web/hands/assets-src/Boxer.LICENSE.txt`) driven by authored clips retarget-baked from the project's own boxing motion (idle, movement, guards, all punches both hands, reactions, knockdown, get-up), phase-locked to the authoritative simulation via combat-manifest.json. All arena meshes, textures, and sounds remain generated in code. **Blood defaults to graphic `full` mode**; Settings can select `full`, `reduced`, or `off` without changing authoritative trauma or HUD information. Settings also provide master audio volume, haptics on/off, and reduced motion. Audio unlocks only after user interaction and suspends while hidden; haptics are feature-detected and limited to bounded authoritative impact events. Reduced motion removes particles/shake while preserving match state and private get-up information.

Before a public release, review Discord's current violent-content rules, age rating/restriction controls, store/application disclosures, and regional requirements. Do not add external/borrowed assets, commercial boxing-game UI, audio, animation traces, real-fighter or celebrity likenesses, sanctioning-body/brand marks, logos, or recognizable trade dress. All fighter art, interface, motion, terminology, and sound must remain original.

Expand Down Expand Up @@ -600,6 +600,8 @@ git diff --exit-code -- src/intelstream/hands/static

`npm run build` typechecks, writes exactly `index.html`, `assets/hands.js`, and `assets/hands.css` into `src/intelstream/hands/static`, and runs the scanner. Those generated files are committed so production Python installations do not require Node. Change TypeScript/CSS in `web/hands`, rebuild, inspect the output, commit source and generated files together, and require the final `git diff --exit-code` after a clean rebuild. Never hand-edit the generated bundle.

The fixed three-file deployment contract embeds the Boxer rig and its five source textures in `hands.js`; the resulting production script is intentionally about 5.6 MB (about 2.9 MB gzip). This startup-cost tradeoff keeps the Activity self-contained and avoids runtime asset requests.

For a standalone presentation fixture, run `npm --prefix web/hands run dev` and open the printed URL with `?fixture=1`. For the real two-process localhost flow, terminal one runs the Python bot/Hands server with valid Discord credentials:

```bash
Expand Down
65 changes: 38 additions & 27 deletions docs/hands-lab-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,29 +283,34 @@ All five steps landed. Final state:
evaluation lives in the lab overlay and drives the 10 cm alignment contract
instead of replacing the balance-tested server rule.
- The referee remains procedural (out of scope; no clips were harmed).
- Bundle is 1.67 MB (GLB embedded as base64, still the exact three-file
manifest; both static scanners learned to skip URL extraction only for
>4 KB generated literals — credential scanning still applies).
- Bundle is 5,624,575 bytes raw and 2,942,823 bytes at gzip level 9 because
the required three-file manifest embeds the rig and five textures. Build and
wheel checks cap it at 6,000,000 raw / 3,200,000 gzip bytes. Both scanners
extract explicit HTTP(S) URLs across nested and oversized templates while
retaining protocol-relative checks for ordinary literals.

---

# Addendum: imported rigged model (2026-08-06)

**Asset**: KayKit Adventurers `Barbarian.glb` (CC0, Kay Lousberg), vendored with
license in `web/hands/assets-src/`. Inspection: 41-bone armature (dots stripped
by GLTFLoader: `upperarml`, `wristl`, `footl`…), 76 authored clips (in-place,
game-ready), T-pose rest, ~1.35 m tall, faces +Z, 6 skinned parts, 1 palette
texture (1024²), 4,045 triangles, prop meshes (axes/shield/mug/hat/cape)
stripped at generation.
**Asset**: Texel, Inc. `Boxer.glb` (CC BY 4.0), vendored with attribution,
source, checksum, and modification notice in `web/hands/assets-src/Boxer.LICENSE.txt`.
Inspection: 27-joint smooth-skinned armature, approximately human scale, faces
+Z, five skinned parts (head, gloves, body, shoes, pants), five embedded PNG
base-color textures, and 23,050 triangles. The single source animation is
replaced during generation by the HANDS clip set; no compressed-mesh decoder is
required.

**Generation**: `scripts/generate-fighter-glb.ts` retarget-bakes the project's
tuned procedural boxing motion onto the Barbarian armature — world-delta
retarget for torso/head, child-aim retarget for limbs (convention-free), hips
translation scaled by rest hip ratio. 18 clips at authoritative tick rate.
Canonical adapter (`BONE_ADAPTER`) maps canonical bones → model bones with a
hard diagnostic on missing bones. Glove meshes are runtime-attached to the
wrist bones (corner colors); the skin palette texture is embedded base64 and
assigned per fighter clone.
tuned procedural boxing motion onto the Texel armature — world-delta retarget
for torso/head, child-aim retarget for limbs (convention-free), hierarchy-aware
local transforms, floor correction, and hips translation scaled by rest hip
ratio. Baking neutralizes the procedural root so runtime alone owns opponent
facing and stance yaw. It emits 18 clips at the authoritative tick rate. Canonical adapter
(`BONE_ADAPTER`) maps canonical bones → model bones with a hard diagnostic on
missing bones. The model's skinned gloves and pants retain their source textures
and receive per-corner tinting; all five extracted textures are embedded in the
client bundle.

**Model lab**: `/hands/model-lab` (dev) / `?model-lab=1` (vite) — clip player
(play/pause/seek/loop/crossfade), SkeletonHelper, full bone hierarchy print,
Expand All @@ -314,17 +319,23 @@ scale/rotation/position controls, and warnings (unnamed clips/bones, missing
textures, runtime warnings). Same lighting rig as the game.

**Runtime**: `SkinnedBoxer` = SkeletonUtils.clone + per-fighter AnimationMixer +
independently cloned materials only where appearance differs (gear gloves,
skin clearcoat), shared geometry/texture. `BoxingGraph` drives locomotion
blending, committed action playback phase-locked to manifest ticks, reactions,
knockdown/getup, foot locking, and constrained additive IK — corrections
applied strictly after `mixer.update()`. Procedural animator preserved behind
`?procedural=1` and as automatic fallback on GLB load failure.

**Verified**: independent skeletons/animation/materials per fighter (unit
tested), cast+receive shadows, authoritative roots preserved, zero console
errors across captures, SwiftShader software floor avg 59 ms/frame (GPU-bound
real hardware runs far faster; scene ~10k tris).
five independently cloned role-specific materials, with shared geometry and
texture objects. `BoxingGraph` drives locomotion blending, committed action
playback phase-locked to manifest ticks, reactions, knockdown/getup, foot
locking, and constrained additive IK — corrections applied strictly after
`mixer.update()`. The procedural animator remains behind `?procedural=1` and as
automatic fallback on GLB load failure.

**Verified**: independent skeletons, animation state, materials, and head
visibility per fighter; cast+receive shadows; authoritative roots preserved;
all vertices finite and floor-bounded at every keyframe of all 18 clips; all
punch markers agree with the live glove bones within 1 mm. Each fighter is
23,050 triangles. Dynamic gore is held to fixed pools (900 droplets, 90 mist
sprites, 48 gibs, two heads, two stumps, and 48 canvas stains). A same-session
headless SwiftShader comparison measured 9.35 fps for the imported rig versus
9.57 fps for procedural fallback (2.3% lower); the full decapitation interval
measured 8.98 fps (6.2% lower). These software-renderer values are a relative
regression check, not a hardware frame-rate target.

**Missing clips for existing combat actions** (all currently fall back to
idle/locomotion): taunt dance, clinch hold, foul-recovery crouch, weave/slip/
Expand Down
35 changes: 28 additions & 7 deletions scripts/check_hands_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import gzip
import posixpath
import re
import sys
Expand All @@ -16,6 +17,8 @@
f"{PREFIX}assets/hands.js",
f"{PREFIX}assets/hands.css",
}
MAX_HANDS_JS_BYTES = 6_000_000
MAX_HANDS_JS_GZIP_BYTES = 3_200_000
DYNAMIC_CODE = re.compile(r"(?<![\w$])(?:eval\s*\(|new\s+Function\b|Function\s*\()")
SOURCE_MAP = re.compile(r"sourceMappingURL", re.IGNORECASE)
ABSOLUTE_LOCAL_OR_HANDS_ORIGIN = re.compile(
Expand All @@ -24,9 +27,9 @@
r"|[^/\s\"']+/api/hands(?:[/\s?\"']|$))",
re.IGNORECASE,
)
# These are the complete URL literals carried by the reviewed, pinned Discord
# SDK bundle. They are SDK metadata/allowlist values, not Hands runtime targets.
REVIEWED_SDK_URL_LITERALS = {
# These are the complete external URL literals carried by reviewed client code.
# They are pinned SDK metadata or model attribution, not Hands runtime targets.
REVIEWED_EXTERNAL_URL_LITERALS = {
"https://github.com/uuidjs/uuid#getrandomvalues-not-supported",
"https://discord.com",
"https://discordapp.com",
Expand All @@ -37,6 +40,9 @@
"https://staging.discord.co",
"http://localhost:3333",
"https://pax.discord.com",
# Texel Boxer source and CC BY 4.0 license attribution shown in-app.
"https://sketchfab.com/3d-models/boxer-84767168720948b38728ff78ee6f6090",
"https://creativecommons.org/licenses/by/4.0/",
# Pinned three.js: W3C XHTML namespace identifier (never fetched) and a
# JCGT paper citation inside a shader source comment.
"http://www.w3.org/1999/xhtml",
Expand All @@ -47,6 +53,11 @@
re.DOTALL,
)
URL_TOKEN = re.compile(r"""(?:https?:)?(?:/|\\/){2}[^\s"'`\\<>(){},;]+""", re.IGNORECASE)
EXPLICIT_URL_TOKEN = re.compile(r"""https?:(?:/|\\/){2}[^\s"'`\\<>(){},;]+""", re.IGNORECASE)
NETWORK_URL_TOKEN = re.compile(
r"""(?<![:/\\])(?:/|\\/){2}(?:(?:[A-Za-z0-9-]+\.)+[A-Za-z0-9-]+|localhost)(?::[0-9]{1,5})?(?:/|\\/)?[^\s"'`\\<>(){},;]*""",
re.IGNORECASE,
)
CSS_URL_LITERAL = re.compile(
r"""(?:url\(|@import\s+)[\s"']*((?:https?:)?//[^\s"')]+)""", re.IGNORECASE
)
Expand Down Expand Up @@ -152,18 +163,28 @@ def _validate_member_paths(entries: list[zipfile.ZipInfo]) -> list[str]:


def _url_literals(text: str) -> list[str]:
return [
literals = {match.group(0).replace("\\/", "/") for match in EXPLICIT_URL_TOKEN.finditer(text)}
literals.update(
match.group(0).replace("\\/", "/") for match in NETWORK_URL_TOKEN.finditer(text)
)
literals.update(
match.group(0).replace("\\/", "/")
for string in STRING_LITERAL.finditer(text)
# Oversized literals are generated embedded assets (e.g. the base64
# GLB); no real URL is kilobytes long. Credential scanning still runs.
if len(string.group(0)) <= 4096
for match in URL_TOKEN.finditer(string.group("value"))
]
)
return sorted(literals)


def validate_bundle(contents: dict[str, bytes]) -> list[str]:
errors: list[str] = []
script = contents[f"{PREFIX}assets/hands.js"]
if len(script) > MAX_HANDS_JS_BYTES:
errors.append(f"assets/hands.js exceeds {MAX_HANDS_JS_BYTES}-byte budget")
if len(gzip.compress(script, compresslevel=9, mtime=0)) > MAX_HANDS_JS_GZIP_BYTES:
errors.append(f"assets/hands.js exceeds {MAX_HANDS_JS_GZIP_BYTES}-byte gzip budget")
html = contents[f"{PREFIX}index.html"].decode("utf-8", errors="replace")
parser = _BundleHTMLParser()
parser.feed(html)
Expand All @@ -177,7 +198,7 @@ def validate_bundle(contents: dict[str, bytes]) -> list[str]:
if not _relative_reference(reference):
errors.append(f"index.html contains a non-relative runtime reference: {reference}")
for literal in parser.attribute_urls:
if literal not in REVIEWED_SDK_URL_LITERALS:
if literal not in REVIEWED_EXTERNAL_URL_LITERALS:
errors.append(f"index.html: unreviewed external URL literal: {literal}")

for name, raw in contents.items():
Expand All @@ -190,7 +211,7 @@ def validate_bundle(contents: dict[str, bytes]) -> list[str]:
if name.endswith(".css"):
literals.extend(match.group(1) for match in CSS_URL_LITERAL.finditer(text))
for literal in literals:
if literal not in REVIEWED_SDK_URL_LITERALS:
if literal not in REVIEWED_EXTERNAL_URL_LITERALS:
errors.append(f"{name}: unreviewed external URL literal: {literal}")
if ABSOLUTE_LOCAL_OR_HANDS_ORIGIN.search(text):
allowed_localhost_only = text.replace("http://localhost:3333", "")
Expand Down
Loading