exogen/t2-mapper

★ 7Forks 2TypeScriptGitHub ↗Compare

Project website ↗

README

T2 MapGenius™

Map inspector for Tribes 2.

Screenshot of map inspector

Usage

👉 Open the app!

Camera Controls

Click inside the map preview area to capture the mouse.

Key Action
W Forward
A Left
S Backward
D Right
Space Up
Shift Down
Esc Release mouse
Left click Next observer camera
△ Scroll/mouse wheel up Increase speed
▽ Scroll/mouse wheel down Decrease speed

Demo links

Use ?demo=<filename> for a published demo, or ?demo=tribesforever:22945 to load TribesForever demo 22945. Both support timestamp links (&t=60). Add source loaders in src/stream/demoSources.ts.

TribesForever links use the relay configured by RELAY_URL. With DEMO_R2_* configured, the relay caches demos at <DEMO_R2_PREFIX>sources/tribesforever/<id>.rec and redirects browsers to R2. While an import is pending, the browser polls using HTTP 202 and Retry-After; later requests use the stored copy. Results (including errors) are retained for 30 seconds. Concurrent requests share the import, which continues if a browser leaves. Set the relay's DEMOS_BASE_URL to the public URL for that prefix (default: https://demos.tribes2.online/demos).

Each relay allows one source transfer at a time, with up to 16 waiting. Cached demos bypass the queue; overflow receives HTTP 503 with Retry-After: 30. Without R2, queued requests return 503 after 20 seconds; disconnected requests leave the queue. Transfers time out after 5 minutes. Source downloads are throttled to 4 MiB/s, including the no-R2 fallback. The throttled stream goes straight into a single R2 upload. Sources must provide an unencoded file with Content-Length; demos larger than 150 MB (150,000,000 bytes) are rejected before reading the body. Streamed bytes must match that length. Live-server traffic and direct R2 downloads are unaffected.

Each cached .rec has a .rec.json sidecar with fetchedAt, recordedAt (upstream Last-Modified), originalFilename (from Content-Disposition), gameVersion, protocolVersion, and durationMs. Only v25034 demos are accepted. The browser reads this metadata without delaying playback. The download button links to TribesForever, which supplies the original filename. Missing dates/filenames are null. Old cache entries without valid metadata are re-imported once.

Without R2 credentials, the relay streams directly from TribesForever to handle CORS. Deploy the updated relay as well as the client. External demos are not added to the published index and do not use its cast/commentary sidecars.

Precomputed demo seek checkpoints

The relay automatically generates seek sidecars for finalized recordings before adding them to the index, and for TribesForever imports before returning the cached download URL. Existing external cache entries get a sidecar on their next request. One background replay process runs at a time, using GAME_BASE_PATH for collision assets. Set DEMO_CHECKPOINTS_ENABLED=false to disable automatic generation. A generation failure is logged and the demo remains playable; upload failures retain local recordings for the retry sweep. External imports save their cache metadata before generation, so a relay restart can reuse the completed download. Checkpoint failures also leave those imports playable and are retried on a later request. Remote sidecars are checked against the demo's object ETag as well as its size and checkpoint version.

For existing bucket demos, preview a backfill and then run it:

npm run demos:backfill-checkpoints -- --dry-run
npm run demos:backfill-checkpoints

The backfill runs serially, skips current sidecars, and supports --filter, --force, and --asset-root. It uses the relay's DEMO_R2_* credentials from .env.development.local and does not change the demo index or metadata.

Generate a local sidecar with:

npm run demos:checkpoints -- path/to/demo.rec

This writes demo.rec.checkpoints.json, with one checkpoint 60 seconds before each confirmed Match started timeline marker, across all maps. Short lead-ins use the beginning of the recording; demos without a confirmed start get no checkpoints. Cancelled countdowns do not produce checkpoints. Generation replays the demo with collision assets from docs/base; use --asset-root to override that folder or --output to choose the sidecar location. Valid local sidecars are reused on retries; --force regenerates them. Outputs are written atomically.

For local playback, select or drop the demo and its sidecar together. For URL playback, host the sidecar alongside the demo at the same URL with .checkpoints.json appended to its filename. Loading is optional: missing, corrupt, mismatched, or outdated sidecars fall back to ordinary playback. After changes that invalidate persisted decoder or simulation state, bump DEMO_CHECKPOINT_VERSION and rerun the backfill; playback ignores old versions.

Demo heatmaps (experimental)

Add ?features=stats to the URL (or &features=stats alongside other query parameters), load a demo, and select a player in the Stats sidebar accordion. The selection opens a heatmap in command circuit covering the game at the playback cursor, or the next game when the cursor is between matches. Each game has a separate timeline interval and match ID, even when a map repeats. Loading and warmup select the upcoming game's heatmap, which appears once its scene is ready; its samples cover actual match play. Color intensity represents time spent: positions are sampled every eight simulation ticks (256 ms), including time standing still. Dead players are excluded; mounted players use their vehicle's position.

Scanning follows mission loads, kickoffs, and match ends throughout the demo, excluding warmup and countdowns. Mid-match joins use the available positions when the clock or game state establishes that play is underway. Recordings that omit a match's start or end show the recorded portion with a notice. Only positions present in the recording can contribute. Samples are grouped by the player's base name, ignoring case and the game's marked clan tags, so reconnects and tag changes share one heatmap. This also applies to smurfs and accounts with different GUIDs. Scanning runs only with the feature enabled and is cancelled when the demo is replaced or ejected.

Development

Install dependencies:

npm install

Run the dev server:

npm start

Relay Server

The relay server bridges WebSocket connections from the browser to Tribes 2 game servers via UDP. This is necessary because browsers can't open UDP sockets directly.

Local development

First, obtain TribesNext account credentials:

npm run login

This prompts for your TribesNext username and password, downloads the account certificate and encrypted key, and writes them to .env.local.

Then run the relay (or use npm run start:both to run it alongside the Next.js dev server):

npm run relay:dev

Browser input logs

Every browser WebSocket input is logged with event: "browser_input", connectionId, clientIp, peerIp, and a per-connection inputSeq. This includes chat, movement, pings/pongs, and ignored or malformed messages. Audit entries are always emitted at info, independently of LOG_LEVEL. Search chat text in input.args, then use connectionId to trace that browser's other inputs. Connection, disconnect, and socket-error entries carry the same identity.

Payloads over 16 KiB are marked payloadTruncated and include a SHA-256 hash; the input entry itself is never skipped. IPs come from the socket unless RELAY_TRUST_FLY_PROXY=true (set in fly.toml), which uses a valid Fly-Client-IP header and retains the socket peer IP. Enable this only behind Fly's HTTP proxy. X-Forwarded-For is ignored.

Chat

RELAY_CHAT_ENABLED defaults to true. Set it to false or 0 in the relay environment and restart to block outgoing chat from all browser connections through the relay (global, team, and canned voice chat). Incoming chat and native Tribes 2 clients are unaffected. The browser shows its chat input only when the relay reports chatEnabled: true; older relays leave it hidden.

Server passwords

For passworded game servers, set T2_SERVER_PASSWORDS in the relay environment (locally, .env.development.local) to a JSON object:

T2_SERVER_PASSWORDS='{"192.0.2.1:28000":"example-password","My Server":"another-password"}'

Keys match an exact IP:port or an exact, case-sensitive server name. Address matches take priority. The relay uses a matching password only when the server reports that it requires one, for player joins, watch sessions, reconnects, and patrol recording. Passwords remain in the relay: they are excluded from server lists, /health, and browser messages. Store this variable as a production secret and restart the relay after changes. Invalid JSON or non-string values prevent startup with a generic error that does not include the configuration. Passwords must fit the game's wire format: at most 255 single-byte characters, without NUL. An empty value disables that key, including patrol eligibility; an empty address override does not fall back to a name password.

With LOG_LEVEL=info (the default), each join logs the password lookup outcome and whether it matched by address or name, followed by passwordSent on the handshake. Missing credentials and server password rejections produce warnings with the server address. A timeout means no acceptance or rejection arrived; it does not establish that the password is wrong. Password values are never logged.

Deploying to Fly.io

The relay is configured for Fly.io deployment via relay/Dockerfile and fly.toml. It needs a persistent volume for demo recordings and for the game assets used by the CRC integrity check.

1. Create the app and volume:

fly launch          # creates the app (adjust app name in fly.toml if needed)
fly volumes create gamedata --region ord --size 3

2. Set account credentials as secrets:

Run npm run login locally first if you haven't already, then copy the values from .env.local:

fly secrets set \
  T2_ACCOUNT_NAME=... \
  T2_ACCOUNT_PASSWORD=... \
  T2_ACCOUNT_CERTIFICATE=... \
  T2_ACCOUNT_ENCRYPTED_KEY=...

3. Deploy:

fly deploy

4. Game assets on the volume:

At every boot the relay refreshes a sparse git checkout of DTS shapes, DIF interiors, and TER terrain (relay/syncAssets.ts) at /data/t2-mapper, so the volume matches main. Terrain and interiors add about 470 MiB on the first sync; later updates transfer changed files. After a push that adds assets, fly deploy picks up both the new manifest (baked into the image) and the new files. A custom ASSETS_SPARSE_PATTERNS must include all three file types for checkpoint generation. To refresh without a deploy:

fly ssh console -C "node --import=tsx/esm relay/syncAssets.ts"

Environment variables (all optional, with defaults):

Variable Default Description
RELAY_PORT 8765 WebSocket listen port
GAME_BASE_PATH docs/base relative to relay Path to extracted game assets
ASSETS_REPO_* see .env.example Git checkout the Fly image syncs
MANIFEST_PATH src/manifest.json relative to project root Path to resource manifest
T2_MASTER_SERVER master.tribesnext.com Master server for server list queries

Adding game assets

Point add-vl2 at a .vl2 and it walks through every consumer, asking before each step (--dry-run only reports; --yes takes the defaults):

npm run add-vl2 -- path/to/MapPack.vl2

It extracts the archive under docs/base/@vl2 (or updates an existing extraction), reports which existing resources it overrides, converts its .wav (ffmpeg) files, loads .dts/.dif directly, rebuilds src/manifest.json, runs the typecheck, and then offers to commit, push (which deploys the assets and site), and redeploy the relay.

The manifest carries the resource index, the mission list, and each shape's mount-node transforms; regenerate it on its own with npm run build:manifest.

Running scripts

tsx is included to run TypeScript files directly.

Example:

tsx scripts/generate-manifest.ts --quiet

Auto-director and observation traces

Auto-direction is independent of commentary generation. Its shared core must support live snapshots and demos, dynamic planning in the browser, and batch processing without an LLM, TTS, or commentary consumer. DirectorTrackers accepts snapshots from either source; createCastStream is the shared demo camera pipeline used by browser playback and batch generation. The incremental switcher currently supports CTF; other modes retain their batch fallback.

The CTF pipeline advances scanning and decisions on the same half-second grid in both modes, including during seeks. Batch generation cannot use a later tracker enrichment to make an earlier camera decision. New camera shots are staged against the available path before publication; their framing stays fixed while the switcher updates cut times. Finishing a recording adds archive metadata without restaging footage that a dynamic viewer already saw. Existing .cast.json sidecars must be regenerated to use changed director rules.

An optional fact journal records when an event occurred and when each revision actually became available. A flag drop is observable immediately; its later pass/death classification is a new revision. Replay reveals revisions by availability time so a finished dataset cannot leak later interpretation into an earlier prompt.

npm run cast:trace -- demos/s5-damnation.rec --to 790 --out /tmp/director-facts.json --verify

This builds the normal collision world, scans the demo, writes a fact trace, and reports availability latency. --verify compares incremental and batch reads and checks that enabling recording leaves the camera-only dataset unchanged. --step controls the consumer's read cadence, not tracker sampling. The trace contains the entire observed prefix, including facts before the slice of interest. No generation APIs are called.

For a live source, create DirectorTrackers({ factStreamId: matchEpoch }), feed it monotonically advancing snapshots, and call drainFacts() to retrieve new records. Browser/demo callers can supply the same option to createDirectorScanStream or createCastStream. Omit the option for normal camera-only operation. Recording uses a pull queue with no consumer callbacks; drain it regularly when enabled. Start new trackers and a new epoch when the source resets. Fact IDs are stable within an epoch, not across independent connections.

DirectorFactReplay.advanceTo(t) returns only newly available revisions. These are raw director facts, including documented inferences, not curated commentary cues or proof of camera visibility. Fact traces currently cover server events, player deaths, skill-shot announcements, and structure changes; state observations are available through a separate opt-in output.

Supply stateStreamId: matchEpoch to the same tracker/scanner/cast-stream constructors and call drainStates(). It captures scoped players, their names and target generations, flags, team scores, and the signed match clock at the existing player sampling cadence (1 Hz with the normal 0.5-second input grid). Frames are detached before publishing; later renames, scores, and tracker enrichment cannot rewrite them. Raw events still use the faster fact journal.

npm run cast:trace -- demos/s5-damnation.rec --to 790 --out /tmp/director-facts.json --state-out /tmp/director-states.json --verify

DirectorObservationReplay accepts the complete state trace or incremental append(stream.drainStates()) calls. Its observe({ timeSec, availableThroughSec, camera }) reads the most recent sample that satisfies both the picture timestamp and the source availability limit. It never uses a nearest-future sample or an archive shot's midpoint. Replaying a loaded trace can seek; incoming appends must preserve sequence and epoch. The replay retains its history, so dispose it when the match ends.

Camera observations are supplied by the camera owner. After rendering's camera matrix updates, captureDirectorCamera(perspectiveCamera, context) reads the actual pose without moving it. The context supplies the epoch, picture and availability timestamps, stable shot ID/revision, subject, and commitment. Pass that frame to observe; planned/provisional poses never certify what was shown. The adapter supports ordinary perspective cameras; asymmetric subviews and film offsets produce unknown visibility. The scanner's --state-out has an empty camera array because scanning alone does not render the picture.

Observations include state/camera ages and freshness (default maximum ages: 1.5s and 0.25s). Stale state remains explicitly marked; its visibility is unknown. Frustum inclusion is separate from line of sight. An optional consumer-side lineOfSight(eye, point) query may report clear/blocked/unknown against the world at the picture timestamp. Without it, an in-frame point has unknown visibility. These are point estimates, not proof that a whole model was visible. Missing scoped entities do not establish deaths or disconnects.

npm run cast:export-schema regenerates the existing cast schema plus generated/director-facts.schema.json, generated/director-observations.schema.json (state/camera trace), and generated/director-observation.schema.json (a resolved observation). Validate serialized input before constructing a replay. The existing archive cast sidecar contract is unchanged. Publishing authoritative camera IDs/commitments and connecting these observations to CastGenius remain integration work; these APIs do not claim to establish live camera transport.

Camera startup no longer waits for commentary's audio buffer or cue metadata. Audio preloads alongside the camera and joins its clock when ready. If intro metadata is already available, the existing archive intro start rule applies; otherwise the director starts from its own plan. Late metadata never seeks the picture backward to recover missed speech.

Native DIF interiors

Interiors load directly from .dif in the browser and in the headless collision world. No Blender add-on or converted interior .glb is required.

src/dif/dif.ts reads Tribes 2 resource version 44 / interior version 0, using the Blender io_dif reader and the decompiled game's interior reader as references. src/dif/difLoader.ts provides a Three.js DIFLoader (loadAsync, parseAsync, and DOM-free parse) and createDIFModel. It builds indexed meshes with flat plane normals, texture UVs, a separate uv1 lightmap channel, and typed DIFMaterial properties for texture paths and surface flags. The viewer resolves texture names through the asset manifest and applies the existing Torque lighting/fog. Embedded PNG lightmaps are decoded directly; no emissive-slot transport or glTF custom properties are involved. Mesh coordinates use the scene's (y, z, x) axis order without a Blender rotation correction.

The default is the first (highest) detail level; createDIFModel can select a file-order detail index. Normal-state lighting is rendered. Alarm lightmap indices are retained, but animated lights, resource-level path followers, triggers, mirror rendering, and BSP visibility are not implemented. src/dif/difCollision.ts uses detail 0's BSP for raycasts, matching the executable's solid-start and coplanar rules. Clearance uses authored convex hulls and their 16×16 spatial bins, including invisible null surfaces and collision fan masks. Sphere distances account for the instance's rotation and nonuniform scale. Browser and headless worlds share this data; rendering LOD and material batches do not affect collision. Lighting probes use the hit's original surface index and lightmap texture planes directly. Triangle BVHs are built lazily only for consumers that need polygon geometry, such as projected shadow receivers. Vehicle clearance queries can select the resource's dedicated hull set with pointObstructed(point, radius, { interiorHullType: "vehicle" }). As in the executable's InteriorInstance::buildConvex, a nonempty vehicle set replaces ordinary hulls; an absent or empty set falls back to ordinary collision. These hulls use their own compact points and support-vertex feature streams, including polygons that have no render mesh. Raw vehicle winding indices can refer to obsolete authoring points, so contact geometry uses the engine's feature stream mappings instead. Vehicle queries scan hull bounds directly; raycasts and visibility retain the ordinary BSP regardless of hull selection. Later Torque DIF variants are rejected with a format error.

Run npx tsx scripts/check-dif.ts to validate all local DIFs and their geometry.

Native DTS shapes

Shapes load directly from DTS in the viewer and headless collision world. The Blender conversion, Draco decoder shim, and JSON custom-property transport are no longer required. The implementation follows io_scene_dtst3d in DynamixThreeSpaceBlenderAddon and the Torque engine's shape, mesh, animation, and sequence readers under reference/TorqueEngineResources/tribes2-engine/ts.

DTSLoader in src/dts/dtsLoader.ts provides load, loadAsync, and synchronous, DOM-free parse. parseDTS reads versions 15–26, including the old sequential layout, modern guarded buffer lanes, shared meshes, legacy skin tables, and 32-bit indices/secondary UVs/colors in version 26. parseDSQ and mergeDSQ support external sequences from versions 22–26 and remap their nodes by name. ShapeLoader supplies the app's manifest/texture resolution and discovers external DSQs using the same filename-prefix rule as the Blender add-on.

The resulting model contains indexed Three meshes, authored normals, bones, skinned meshes, materials, and one AnimationClip per DTS sequence. Mesh-frame animation switches shared geometry attributes at the engine's discrete keyframe midpoints; texture-coordinate frames and visibility use native tracks too. Binary visibility switches at the midpoint; partial opacity keys interpolate. Blend transforms have their own local hierarchy, and arbitrary-axis scaling keeps its rotation/scale/inverse-rotation factors rather than losing shear. Skins with up to four influences use SkinnedMesh; higher influence counts use exact CPU deformation. IFL texture animation, sorted transparency clusters, billboard mesh flags, decals, material wrapping/blending, detail/bump/reflectance maps, and encoded normals retain their native representation. Translucent materials use their authored alpha blending and depth-write rules; the viewer preserves sorted material groups without extra whole-mesh passes.

IFL frames load once per resource and sampler configuration, including concurrent requests. Atlas frames share their image source and have fixed UV transforms; shape instances select a frame through native animation tracks. Repeating or differently sized frames use individual textures. Loading is progressive: clones created before decoding finishes receive the shared frames automatically. Explosions and projectile bolts sample one native ambient clip on their playback clock for transforms, visibility, geometry frames, and IFLs, including pauses and seeks after the end of a one-shot. DTSShape.time controls unbound viewer IFLs; imageAnimationEnabled = false holds the first image without changing thread state.

The app uses DTSAnimationMixer, a small adapter over Three's public animation APIs. Object visibility, geometry/UV frames, decals, and IFLs follow Torque's per-property thread priority: non-blend sequences precede blend sequences, then higher priorities win. Unclaimed properties keep lower-priority outputs or their authored defaults. These states are absolute even in blend sequences; only node transforms are additive. Native mixers do the interpolation and property binding; disjoint state clips are cached and rebuilt only when ownership changes. Player jets, vehicle jets, ghost threads, and weapon image states all drive native actions. Stop holds the first key, pause holds the current position, and reversing a ghost thread preserves its position. Weapon states replace their sequence directly, following ShapeBase::setImageState rather than adding a crossfade.

Numeric buffers use typed arrays, with zero-copy reads for aligned DTS lanes. Only detail 0 and initially enabled decals become Three meshes at load time. Other details and inactive decals stay shared descriptors; their geometry is compiled once on first use, then cloned with instance-owned materials and bones. The app fixes both DTS and DIF rendering at detail 0, so other visual LODs never become scene objects during ordinary playback. Collision and LOS lookup reads the authored mesh table independently, without creating visual hull meshes. Bones and object animation controls remain eager for animation and attachments. DTSShape.onMeshAdded lets the host initialize late meshes with the current skin, lighting, fade/cloak and shadow state. Explicit ensureAllDetails() or buildDTS(data, { lazy: false }) expands meshes for inspection tools.

Immutable geometry attributes are shared across instances; mutable frame, decal, and sorted-index views are allocated only when used. Previously selected hidden branches skip world-matrix updates and refresh before becoming visible. Decals upload buffers only when their state changes. Sorted meshes retain their authored order while batching adjacent primitives with the same material. scripts/benchmark-dts.ts compares eager/lazy construction, cloning, and frame updates with external DSQs and rigid batching; scripts/check-dts-lazy.ts compares both paths across installed assets, animations, LODs and enabled decals. scripts/profile-dts-demo.ts profiles playback in the running dev server.

The shared shape loader combines compatible opaque parts once per asset, before cloning. Stationary parts use an ordinary merged Mesh; articulated parts use a Three.js SkinnedMesh, with each vertex following its original DTS bone. Instances share combined geometry and keep independent skeletons and runtime-selected skins. Lower LODs, merge transitions, hidden parts and fades use the authored meshes; scale-animated parts keep their original normal transforms. Damage decals, jet effects, mounts and collision geometry retain their own paths. scripts/check-dts-rigid-batch.ts compares all DTS assets and animation clips against the authored meshes; scripts/compare-dts-batches.ts compares general batching, player-only batching, and original draws in the same browser session with the scene and poses held fixed.

A shared instance renderer is enabled for every DTS category: players, scenery, vehicles, mounted weapons, items, projectiles, and effects. Use dtsInstancing=0 in the viewer URL to compare with ordinary draws. Compatible visible geometry shares InstancedMesh draws with independent transforms, poses, colors, lighting, opacity, and base-texture UV transforms. Rigid draws need no bone texture; articulated and weighted skins use a floating-point bone palette with Three's normal GPU skinning chunks. Mixers, logical nodes, mounts, picking, and collision stay on their conventional Three objects.

The common case shares the original texture directly. Different same-size skins or IFL images can promote a draw to a GPU texture array, copied through Three's texture API without canvas readback or resampling. Arrays respect GPU limits and a 64 MiB limit per draw. Texture dimensions and sampler configuration must match. Authored mip chains and additional material maps use native samplers.

The adapter consumes Three's culled render list after DTS detail/frame selection. Each visible mesh/material group registers lazily and keeps its batch membership. Reusable scalar snapshots detect changes to geometry, samplers, shaders and draw state without rebuilding signature arrays each frame. Weak registrations do not retain despawned meshes; unused draw resources retire after 600 frames, checked in 60-frame sweeps. Matrices, colors, lighting, opacity and UVs upload only their changed instance ranges through Three's buffer API; unchanged bone palettes skip texture uploads as well. Changes within Float32 precision require no upload. Transparent draws combine only in consecutive compatible runs, preserving their order among other scene objects. Camera-dependent sorted meshes select shared immutable index buffers. Multiple materials, polygon orders, geometry frames, and GPU blend/depth states can require multiple draws even for one shape. Singletons and unsupported custom rendering states retain native draws.

scripts/compare-dts-instances.ts <demo-url> [output-prefix] [seek-seconds] compares render time, draw calls, and pixels on a real demo and a 120-player crowd with independently advancing animations and resolved custom skins. scripts/check-dts-instances.ts [output-prefix] checks real foliage, flags, weapons, vehicles, items, and effects in normal, animated, and fading states. See shared DTS instancing for boundaries, measurements, and reproduction commands. Fewer draws do not imply lower CPU frame time; animation and scene-graph updates are still performed on the CPU.

DTS-only information remains in typed model.data, mesh bindings, and DTSAnimationClip.sequence/triggers, without serialization through userData. Available ground motion is a separate groundMotion clip with the engine's implicit identity key. DTS 22/23 do not serialize embedded ground samples; those declarations are retained but are not turned into invented motion. Trigger dispatch, node-pose blending, and root-motion application remain the host animation controller's responsibility. DTSAnimationClip.objectAnimation holds absolute object-state tracks for the adapter; the complete clip retains standalone AnimationMixer compatibility with Three's normal blending semantics.

The viewer keeps detail 0 for rendering. Camera collision follows the engine's authored DTS details: TSStatic uses Collision-1 through Collision-8; StaticShape prefers LOS-9 through LOS-16, falling back per slot to the corresponding collision detail. Rays clip against each mesh's convex face planes, with animated node transforms, object visibility, and mesh frames. There are no vegetation-name, material-transparency, or minimum-size filters. Browser and headless worlds share this selection and collision implementation. All details and subshapes remain available: set scene.detailLevel to a file-order index, or null for projected-size selection, and set scene.viewportHeight to the viewport height. Authored merge vertices and UVs interpolate between details through scene.intraDetailLevel without changing shared source buffers. Collision details remain hidden unless explicitly selected with ignoreDetailSize. createDTSImpostors(model, renderer) can build camera-facing sprite views for generated billboard LODs after their textures load; until then those levels render their source mesh detail. Shape-local coordinates are (-x, z, y), Y up, matching the viewer's existing shape/mount placement; the manifest now extracts mount transforms from DTS directly.

Audit local assets, including merged DSQ animations, with:

node --import=tsx scripts/check-dts.ts

Contributors

exogenbmathews

Issues